From a9715cdf9a934ea2e84b7fa15f9d4c3e930804c2 Mon Sep 17 00:00:00 2001 From: rOyalFruit Date: Tue, 4 Feb 2025 16:37:17 +0900 Subject: [PATCH 01/89] =?UTF-8?q?Fix:=20=EB=8F=84=EC=84=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BookDetailPage 컴포넌트에서 params 비동기 처리 구현 - BookDetailPageProps 인터페이스 업데이트: params를 Promise 타입으로 변경 - fetch 호출 시 await로 해결된 id 값 사용 - Route used `params.id`. `params` should be awaited before using its properties" 에러 해결 --- frontend/app/books/[id]/page.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/app/books/[id]/page.tsx b/frontend/app/books/[id]/page.tsx index 0efd44f..89be299 100644 --- a/frontend/app/books/[id]/page.tsx +++ b/frontend/app/books/[id]/page.tsx @@ -5,11 +5,12 @@ import { BookTabs } from '@/app/components/book/BookTabs'; import type { Book } from '@/types/book'; interface BookDetailPageProps { - params: { id: string }; + params: Promise<{ id: string }>; // 1. Promise 타입으로 변경 } export default async function BookDetailPage({ params }: BookDetailPageProps) { - const response = await fetch(`http://localhost:8080/books/${params.id}`, { + const { id } = await params; // 2. params await 처리 + const response = await fetch(`http://localhost:8080/books/${id}`, { cache: 'no-store', }); From ea15e1001ad33bcd2ac0768470d860007f3bfc17 Mon Sep 17 00:00:00 2001 From: seng Date: Tue, 4 Feb 2025 17:01:48 +0900 Subject: [PATCH 02/89] =?UTF-8?q?fix:=20memberId=EB=A5=BC=20orderId?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EC=97=AC=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ll/nbe342team8/domain/order/order/dto/OrderDTO.java | 2 +- .../nbe342team8/domain/order/order/service/OrderService.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/dto/OrderDTO.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/dto/OrderDTO.java index 3225cfb..17e2bc6 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/dto/OrderDTO.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/dto/OrderDTO.java @@ -7,7 +7,7 @@ @Getter @AllArgsConstructor public class OrderDTO { - private Long memberId; + private Long orderId; private String orderStatus; private long totalPrice; } \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/service/OrderService.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/service/OrderService.java index 4fcdb05..6f5a8f1 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/service/OrderService.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/service/OrderService.java @@ -40,7 +40,8 @@ public List getOrdersByMemberId(Long memberId) { // DTO로 변환하여 반환 return orders.stream() - .map(order -> new OrderDTO(order.getMember().getId(), + .map(order -> new OrderDTO( + order.getId(), order.getOrderStatus().name(), order.getTotalPrice())) .collect(Collectors.toList()); From c535762ef64bc2fb8b570cc62236837de7cf0c64 Mon Sep 17 00:00:00 2001 From: rOyalFruit Date: Tue, 4 Feb 2025 17:13:29 +0900 Subject: [PATCH 03/89] Remove application.yml from git history --- .gitignore | 1 + backend/src/main/resources/application.yml | 20 -------------------- 2 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 backend/src/main/resources/application.yml diff --git a/.gitignore b/.gitignore index 5fb2f2f..67fe74d 100644 --- a/.gitignore +++ b/.gitignore @@ -205,4 +205,5 @@ gradle-app.setting .idea/ db_dev.mv.db +application.yml # End of https://www.toptal.com/developers/gitignore/api/nextjs,intellij,java,gradle \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml deleted file mode 100644 index 4068acc..0000000 --- a/backend/src/main/resources/application.yml +++ /dev/null @@ -1,20 +0,0 @@ -spring: - datasource: - url: jdbc:h2:./db_dev;MODE=MySQL - driver-class-name: org.h2.Driver - username: sa - password: - jpa: - hibernate: - ddl-auto: update # 스키마 자동 업데이트 (create, create-drop, update, none 가능) - properties: - hibernate: - dialect: org.hibernate.dialect.H2Dialect - show-sql: true # SQL 쿼리 로그를 출력 - h2: - console: - enabled: true # H2 콘솔 활성화 - path: /h2-console # H2 콘솔 경로 (기본값: /h2-console) - -aladin: - ttbkey: ttbcameogu1634001 \ No newline at end of file From a8911cf988e99146e521849190f561d00e66c7a5 Mon Sep 17 00:00:00 2001 From: rOyalFruit Date: Wed, 5 Feb 2025 02:16:12 +0900 Subject: [PATCH 04/89] =?UTF-8?q?feat:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(=ED=9A=8C=EC=9B=90/=EB=B9=84=ED=9A=8C=EC=9B=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 회원: 임시로 쿠키의 member_id를 사용하여 서버에 장바구니 추가 요청 - user_session, member_id 쿠키 존재 시 로그인 상태로 간주 - POST /cart/{book-id}/{member-id} 엔드포인트 호출 - 비회원: 로컬스토리지를 사용하여 장바구니 데이터 관리 - 키: guest_cart - 데이터: bookId, quantity 정보 저장 - 동일 상품 추가 시 수량 증가 처리 추가 구현 사항: - 장바구니 담기 버튼 컴포넌트 분리 - 수량 선택 및 총 상품금액 표시 기능 --- .../cart/controller/CartController.java | 4 +- frontend/app/books/[id]/page.tsx | 2 +- frontend/app/components/book/BookInfo.tsx | 56 ++++++++++-- .../app/components/common/AddToCartButton.tsx | 45 +++++++++ frontend/utils/auth.ts | 25 +++++ frontend/utils/cart.ts | 91 +++++++++++++++++++ 6 files changed, 210 insertions(+), 13 deletions(-) create mode 100644 frontend/app/components/common/AddToCartButton.tsx create mode 100644 frontend/utils/auth.ts create mode 100644 frontend/utils/cart.ts diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/cart/controller/CartController.java b/backend/src/main/java/com/ll/nbe342team8/domain/cart/controller/CartController.java index 0697493..94cfa51 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/cart/controller/CartController.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/cart/controller/CartController.java @@ -31,11 +31,9 @@ public class CartController { @PostMapping("/{book-id}/{member-id}") public void addCart(@PathVariable("book-id") long bookId, @PathVariable("member-id") long memberId, - @RequestParam("quantity") int quantity) { + @RequestBody CartItemRequestDto cartItemRequestDto) { - CartItemRequestDto cartItemRequestDto = new CartItemRequestDto(bookId, quantity); CartRequestDto cartRequestDto = new CartRequestDto(List.of(cartItemRequestDto)); - updateCartItems(memberId, cartRequestDto); } diff --git a/frontend/app/books/[id]/page.tsx b/frontend/app/books/[id]/page.tsx index 89be299..e2fb47a 100644 --- a/frontend/app/books/[id]/page.tsx +++ b/frontend/app/books/[id]/page.tsx @@ -5,7 +5,7 @@ import { BookTabs } from '@/app/components/book/BookTabs'; import type { Book } from '@/types/book'; interface BookDetailPageProps { - params: Promise<{ id: string }>; // 1. Promise 타입으로 변경 + params: Promise<{ id: string }>; } export default async function BookDetailPage({ params }: BookDetailPageProps) { diff --git a/frontend/app/components/book/BookInfo.tsx b/frontend/app/components/book/BookInfo.tsx index 4b6e770..fb38f82 100644 --- a/frontend/app/components/book/BookInfo.tsx +++ b/frontend/app/components/book/BookInfo.tsx @@ -1,30 +1,49 @@ // components/book/BookInfo.tsx 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; import Image from 'next/image'; import type { Book } from '@/types/book'; import { addToCart } from '@/utils/api'; +import { getMemberId, isLoggedIn } from '@/utils/auth'; +import { AddToCartButton } from '@/app/components/common/AddToCartButton'; interface BookInfoProps { book: Book; } export const BookInfo: React.FC = ({ book }) => { + const [quantity, setQuantity] = useState(1); // 수량 상태 추가 const router = useRouter(); + // 수량 변경 핸들러 + const handleQuantityChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value); + if (value > 0) { + setQuantity(value); + } + }; + const handleAddToCart = async () => { try { - await addToCart(book.id, 1, 1); - router.push('/cart'); + if (isLoggedIn()) { + const memberId = getMemberId(); + if (!memberId) throw new Error('로그인 정보가 없습니다'); + await addToCart(book.id, memberId, 1); + } else { + await addToCart(book.id, 1, 1); + } } catch (error) { console.error('장바구니 추가 실패', error); + alert(error instanceof Error ? error.message : '장바구니 추가에 실패했습니다'); } }; const averageRating = book.reviewCount > 0 ? (book.rating / book.reviewCount).toFixed(1) : '평점 없음'; + const totalPrice = book.priceSales * quantity; + return (
{' '} @@ -63,13 +82,32 @@ export const BookInfo: React.FC = ({ book }) => { 평점: {averageRating} ({book.reviewCount}개 리뷰)

+
+
+ + +
+
+ 총 상품금액 +

{totalPrice.toLocaleString()}원

+
+
- + + ); +}; diff --git a/frontend/utils/auth.ts b/frontend/utils/auth.ts new file mode 100644 index 0000000..0817de5 --- /dev/null +++ b/frontend/utils/auth.ts @@ -0,0 +1,25 @@ +// utils/auth.ts +export const isLoggedIn = () => { + if (typeof window === 'undefined') return false; + const cookies = document.cookie.split(';').reduce( + (acc, cookie) => { + const [name, value] = cookie.trim().split('='); + acc[name] = value; + return acc; + }, + {} as Record, + ); + return !!cookies['user_session']; +}; + +export const getMemberId = () => { + const cookies = document.cookie.split(';').reduce( + (acc, cookie) => { + const [name, value] = cookie.trim().split('='); + acc[name] = value; + return acc; + }, + {} as Record, + ); + return cookies['member_id'] || null; +}; diff --git a/frontend/utils/cart.ts b/frontend/utils/cart.ts new file mode 100644 index 0000000..2f390b4 --- /dev/null +++ b/frontend/utils/cart.ts @@ -0,0 +1,91 @@ +// utils/cart.ts +import { getMemberId, isLoggedIn } from '@/utils/auth'; + +const CART_KEY = 'guest_cart'; + +export const getGuestCart = (): CartItem[] => { + if (typeof window === 'undefined') return []; + const cart = localStorage.getItem(CART_KEY); + return cart ? JSON.parse(cart) : []; +}; + +export const saveGuestCart = (cart: CartItem[]) => { + localStorage.setItem(CART_KEY, JSON.stringify(cart)); +}; + +interface CartItem { + bookId: number; + quantity: number; +} + +// API 통신 함수 +export const addToCart = async (bookId: number, memberId: number, quantity: number) => { + if (isLoggedIn()) { + const memberId = getMemberId(); + if (!memberId) throw new Error('로그인이 필요합니다'); + + const response = await fetch(`http://localhost:8080/cart/${bookId}/${memberId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ bookId, quantity }), + }); + + if (!response.ok) throw new Error('장바구니 추가 실패'); + } else { + const cart = getGuestCart(); + const existingItem = cart.find((item) => item.bookId === bookId); + + if (existingItem) { + existingItem.quantity += quantity; + } else { + cart.push({ bookId, quantity }); + } + + saveGuestCart(cart); + } +}; + +// 로그인 시 호출할 동기화 함수 +export const syncGuestCart = async () => { + if (!isLoggedIn()) return; + + const guestCart = getGuestCart(); + if (guestCart.length === 0) return; + + const memberId = getMemberId(); + if (!memberId) throw new Error('회원 ID를 찾을 수 없습니다.'); + + try { + await Promise.all( + guestCart.map((item) => + fetch(`http://localhost:8080/cart/${item.bookId}/${memberId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + bookId: item.bookId, + quantity: item.quantity, + }), + }), + ), + ); + localStorage.removeItem(CART_KEY); + } catch (error) { + console.error('장바구니 동기화 실패', error); + throw error; + } +}; + +export const fetchCart = async (): Promise => { + if (isLoggedIn()) { + const memberId = getMemberId(); + if (!memberId) throw new Error('회원 ID를 찾을 수 없습니다.'); + + const response = await fetch(`http://localhost:8080/cart/${memberId}`); + if (!response.ok) throw new Error('장바구니 조회 실패'); + return response.json(); + } else { + return getGuestCart(); + } +}; From e8dd3f99e41af70a8b0168ad5a1223568a295129 Mon Sep 17 00:00:00 2001 From: seng Date: Wed, 5 Feb 2025 10:41:38 +0900 Subject: [PATCH 05/89] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=97=90=EC=84=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DetailOrderController.java | 1 + .../domain/order/order/entity/Order.java | 1 + .../global/security/SecurityConfig.java | 32 ++++- frontend/app/my/orders/[orderId]/page.tsx | 128 ++++++++---------- frontend/app/my/orders/page.tsx | 8 +- 5 files changed, 94 insertions(+), 76 deletions(-) diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/controller/DetailOrderController.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/controller/DetailOrderController.java index 8475012..111216c 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/controller/DetailOrderController.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/controller/DetailOrderController.java @@ -18,6 +18,7 @@ public class DetailOrderController { public DetailOrderController(DetailOrderService detailOrderService){ this.detailOrderService = detailOrderService; } + //주문상세조회 @GetMapping("/{orderId}/details") public ResponseEntity> getDetailOrders(@PathVariable Long orderId){ List detailOrders = detailOrderService.getDetailOrdersByOrderId(orderId); diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java index ae850ac..3a831c3 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java @@ -31,4 +31,5 @@ public enum OrderStatus{ DELIVERY, COMPLETE } + } \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java b/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java index e78b7d3..c5f02dc 100644 --- a/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java @@ -2,19 +2,43 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; + +import java.util.Collections; +import java.util.List; @Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .authorizeHttpRequests(authz -> authz - .anyRequest().permitAll() + .csrf((csrf) -> csrf + .ignoringRequestMatchers(new AntPathRequestMatcher("/h2-console/**"))) + .csrf(AbstractHttpConfigurer::disable) + .headers((headers) -> headers + .addHeaderWriter(new XFrameOptionsHeaderWriter( + XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN)) + ) - .csrf(csrf -> csrf.disable()) // CSRF 비활성화 ; + return http.build(); } } \ No newline at end of file diff --git a/frontend/app/my/orders/[orderId]/page.tsx b/frontend/app/my/orders/[orderId]/page.tsx index 37ac04e..0c0515f 100644 --- a/frontend/app/my/orders/[orderId]/page.tsx +++ b/frontend/app/my/orders/[orderId]/page.tsx @@ -1,82 +1,72 @@ -"use client"; -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; // useRouter 사용 +import React, { useState, useEffect } from "react"; -export default function OrderDetailPage() { - const router = useRouter(); - const { orderId } = router.query; // useRouter로 query를 받는 방식 - - const [order, setOrder] = useState(null); - const [error, setError] = useState(""); // error state 추가 +const MyOrders = () => { + const [memberId, setMemberId] = useState(null); // memberId를 null로 시작 + const [orders, setOrders] = useState([]); // 응답 받을 주문 목록 + // URL에서 memberId 쿼리 파라미터를 읽는 useEffect useEffect(() => { - if (!orderId) { - setError("주문 ID가 없습니다."); - return; - } - - fetch(`http://localhost:8080/my/orders/${orderId}/details`) - .then((res) => { - if (!res.ok) { - throw new Error(`HTTP 오류! 상태: ${res.status}`); - } - return res.json(); - }) - .then((data) => { - console.log(data); // 응답 데이터 확인용 - setOrder(data); - }) - .catch((err) => { - console.error("주문 상세 정보 불러오기 실패", err); - setError("주문 상세 정보를 불러오는 데 실패했습니다."); - }); - }, [orderId]); - - const handleDelete = async () => { - const confirmDelete = confirm("정말 주문을 삭제하시겠습니까?"); - if (!confirmDelete) return; - - const response = await fetch(`http://localhost:8080/my/orders/${orderId}`, { - method: "DELETE", - }); + const params = new URLSearchParams(window.location.search); // URL 쿼리 파라미터 읽기 + const id = params.get('memberId'); - if (response.ok) { - alert("주문이 삭제되었습니다."); - router.push("/my/orders"); - } else { - alert("주문 삭제 실패!"); + if (id) { + setMemberId(Number(id)); // memberId 상태 업데이트 } - }; + }, []); // 컴포넌트가 처음 렌더링될 때 한 번만 실행 - if (error) { - return

{error}

; // 오류 메시지 표시 - } + console.log(memberId) + // memberId가 바뀔 때마다 API 요청을 보내는 useEffect + useEffect(() => { + if (memberId === null) return; // memberId가 null일 때 API 요청을 하지 않음 + + console.log("API 요청 memberId:", memberId); // ✅ 요청할 memberId 확인 + const fetchOrders = async () => { + try { + console.log(memberId) + const response = await fetch(`http://localhost:8080/my/orders?memberId=${memberId}`); + const data = await response.json(); + console.log("받은 데이터:", data); // ✅ 응답 데이터 확인 + setOrders(data); // 응답받은 주문 목록 업데이트 + } catch (error) { + console.error("에러 발생:", error); + } + }; - if (!order) return

로딩 중...

; + fetchOrders(); + }, [memberId]); // memberId가 바뀔 때마다 실행 return ( -
-

주문 상세 정보

-

주문 ID: {order.orderId}

-

총 가격: {order.totalPrice}원

-

배송 상태: {order.deliveryStatus}

+
+

주문 목록

+ + {/* memberId 변경을 위한 입력 필드 */} + { + const newMemberId = Number(e.target.value); + console.log("변경된 memberId:", newMemberId); // 상태 변경 확인 + setMemberId(newMemberId); // memberId 상태 업데이트 + }} + /> -

상품 목록

+ {/* 주문 목록 출력 */}
    - {order.books.map((book) => ( -
  • -

    책 제목: {book.title}

    -

    수량: {book.quantity}개

    -
  • - ))} + {orders.length === 0 ? ( +
  • 주문이 없습니다.
  • + ) : ( + orders.map((order: any) => ( +
  • +
    주문 ID: {order.orderId}
    +
    회원 ID: {order.memberId}
    +
    주문 상태: {order.orderStatus}
    +
    총 가격: {order.totalPrice}원
    +
  • + )) + )}
- - -
+
); -} \ No newline at end of file +}; + +export default MyOrders; \ No newline at end of file diff --git a/frontend/app/my/orders/page.tsx b/frontend/app/my/orders/page.tsx index a9f1d24..98fc177 100644 --- a/frontend/app/my/orders/page.tsx +++ b/frontend/app/my/orders/page.tsx @@ -1,14 +1,16 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; export default function OrdersPage() { + const searchParams = useSearchParams() const [orders, setOrders] = useState([]); const [error, setError] = useState(""); const router = useRouter(); useEffect(() => { - const memberId = 1; // 예시로 memberId를 설정 + const memberId = searchParams.get("memberId") + if (!memberId) return fetch(`http://localhost:8080/my/orders?memberId=${memberId}`) .then((res) => { if (!res.ok) { @@ -24,7 +26,7 @@ export default function OrdersPage() { console.error("주문 목록 불러오기 실패", err); setError("주문 목록을 불러오는 데 실패했습니다."); }); - }, []); + }, [searchParams.get("memberId")]); // orders가 배열인지 확인 if (error) { From ceb38f32ca7e7e431013601c9d155444938c474e Mon Sep 17 00:00:00 2001 From: seng Date: Wed, 5 Feb 2025 11:19:49 +0900 Subject: [PATCH 06/89] =?UTF-8?q?fix:=20=EC=A3=BC=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/my/orders/[orderId]/details/page.tsx | 51 +++++++++++++ frontend/app/my/orders/[orderId]/page.tsx | 72 ------------------- 2 files changed, 51 insertions(+), 72 deletions(-) create mode 100644 frontend/app/my/orders/[orderId]/details/page.tsx delete mode 100644 frontend/app/my/orders/[orderId]/page.tsx diff --git a/frontend/app/my/orders/[orderId]/details/page.tsx b/frontend/app/my/orders/[orderId]/details/page.tsx new file mode 100644 index 0000000..f033bae --- /dev/null +++ b/frontend/app/my/orders/[orderId]/details/page.tsx @@ -0,0 +1,51 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; + +export default function OrderDetailPage() { + const { orderId } = useParams(); + const [order, setOrder] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + if (!orderId) return; + + fetch(`http://localhost:8080/my/orders/${orderId}/details`) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP 오류! 상태: ${res.status}`); + } + return res.json(); + }) + .then((data) => { + console.log("받은 데이터:", data); // 개발자 도구에서 확인 + if (Array.isArray(data) && data.length > 0) { + setOrder(data[0]); // 배열이면 첫 번째 요소 사용 + } else { + setError("주문 정보를 찾을 수 없습니다."); + } + }) + .catch((err) => { + console.error("주문 상세 정보 불러오기 실패", err); + setError("주문 상세 정보를 불러오는 데 실패했습니다."); + }); + }, [orderId]); + + if (error) { + return

{error}

; + } + + if (!order) { + return

로딩 중...

; + } + + return ( +
+

주문 상세 정보

+

주문 ID: {order.orderId}

+

책 ID: {order.bookId}

+

책 수량: {order.bookQuantity}

+

배송 상태: {order.deliveryStatus}

+
+ ); +} \ No newline at end of file diff --git a/frontend/app/my/orders/[orderId]/page.tsx b/frontend/app/my/orders/[orderId]/page.tsx deleted file mode 100644 index 0c0515f..0000000 --- a/frontend/app/my/orders/[orderId]/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { useState, useEffect } from "react"; - -const MyOrders = () => { - const [memberId, setMemberId] = useState(null); // memberId를 null로 시작 - const [orders, setOrders] = useState([]); // 응답 받을 주문 목록 - - // URL에서 memberId 쿼리 파라미터를 읽는 useEffect - useEffect(() => { - const params = new URLSearchParams(window.location.search); // URL 쿼리 파라미터 읽기 - const id = params.get('memberId'); - - if (id) { - setMemberId(Number(id)); // memberId 상태 업데이트 - } - }, []); // 컴포넌트가 처음 렌더링될 때 한 번만 실행 - - console.log(memberId) - // memberId가 바뀔 때마다 API 요청을 보내는 useEffect - useEffect(() => { - if (memberId === null) return; // memberId가 null일 때 API 요청을 하지 않음 - - console.log("API 요청 memberId:", memberId); // ✅ 요청할 memberId 확인 - const fetchOrders = async () => { - try { - console.log(memberId) - const response = await fetch(`http://localhost:8080/my/orders?memberId=${memberId}`); - const data = await response.json(); - console.log("받은 데이터:", data); // ✅ 응답 데이터 확인 - setOrders(data); // 응답받은 주문 목록 업데이트 - } catch (error) { - console.error("에러 발생:", error); - } - }; - - fetchOrders(); - }, [memberId]); // memberId가 바뀔 때마다 실행 - - return ( -
-

주문 목록

- - {/* memberId 변경을 위한 입력 필드 */} - { - const newMemberId = Number(e.target.value); - console.log("변경된 memberId:", newMemberId); // 상태 변경 확인 - setMemberId(newMemberId); // memberId 상태 업데이트 - }} - /> - - {/* 주문 목록 출력 */} -
    - {orders.length === 0 ? ( -
  • 주문이 없습니다.
  • - ) : ( - orders.map((order: any) => ( -
  • -
    주문 ID: {order.orderId}
    -
    회원 ID: {order.memberId}
    -
    주문 상태: {order.orderStatus}
    -
    총 가격: {order.totalPrice}원
    -
  • - )) - )} -
-
- ); -}; - -export default MyOrders; \ No newline at end of file From 6be091a072034aba8ca23dbe9993bf5542c0e6c1 Mon Sep 17 00:00:00 2001 From: seng Date: Wed, 5 Feb 2025 11:46:02 +0900 Subject: [PATCH 07/89] =?UTF-8?q?chore:=20=EC=B4=88=EA=B8=B0=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=88=98=EC=A0=95=20-=20memberId=3D2?= =?UTF-8?q?=EC=9D=98=20=EC=A3=BC=EB=AC=B8=203=EA=B0=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/data/DataInitializer.java | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java index 46f6200..fbf108c 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java @@ -56,13 +56,13 @@ public void init() { bookRepository.save(book1); bookRepository.save(book2); } - - // 주문 데이터 초기화 +// 주문 데이터 초기화 if (orderRepository.count() == 0) { Member member1 = memberRepository.findById(1L).orElseThrow(); // 이미 초기화된 회원을 가져옴 Member member2 = memberRepository.findById(2L).orElseThrow(); Member member3 = memberRepository.findById(3L).orElseThrow(); + // 기존 주문 Order order1 = new Order(member1, Order.OrderStatus.COMPLETE, 2500); Order order2 = new Order(member2, Order.OrderStatus.ORDERED, 4500); Order order3 = new Order(member3, Order.OrderStatus.ORDERED, 3500); @@ -70,16 +70,35 @@ public void init() { orderRepository.save(order2); orderRepository.save(order3); + // member2에게 3개의 주문 추가 + Order order4 = new Order(member2, Order.OrderStatus.ORDERED, 5000); + Order order5 = new Order(member2, Order.OrderStatus.DELIVERY, 3200); + Order order6 = new Order(member2, Order.OrderStatus.COMPLETE, 1500); + + orderRepository.save(order4); + orderRepository.save(order5); + orderRepository.save(order6); + // 주문 세부 사항 (DetailOrder) Book book1 = bookRepository.findById(1L).orElseThrow(); // 이미 초기화된 상품을 가져옴 Book book2 = bookRepository.findById(2L).orElseThrow(); + // 기존 주문에 DetailOrder 추가 DetailOrder detailOrder1 = new DetailOrder(order1, book1, 2, DetailOrder.DeliveryStatus.PENDING); DetailOrder detailOrder2 = new DetailOrder(order2, book2, 3, DetailOrder.DeliveryStatus.PENDING); DetailOrder detailOrder3 = new DetailOrder(order3, book1, 1, DetailOrder.DeliveryStatus.PENDING); + + // member2의 추가된 주문에 DetailOrder 추가 + DetailOrder detailOrder4 = new DetailOrder(order4, book2, 2, DetailOrder.DeliveryStatus.PENDING); + DetailOrder detailOrder5 = new DetailOrder(order5, book1, 1, DetailOrder.DeliveryStatus.SHIPPED); + DetailOrder detailOrder6 = new DetailOrder(order6, book2, 2, DetailOrder.DeliveryStatus.DELIVERED); + detailOrderRepository.save(detailOrder1); detailOrderRepository.save(detailOrder2); detailOrderRepository.save(detailOrder3); + detailOrderRepository.save(detailOrder4); + detailOrderRepository.save(detailOrder5); + detailOrderRepository.save(detailOrder6); } } } \ No newline at end of file From f6662f35bd82761f142497ce6ffd3c2faf4421ca Mon Sep 17 00:00:00 2001 From: pcyscott Date: Wed, 5 Feb 2025 11:50:08 +0900 Subject: [PATCH 08/89] OAuth-back --- backend/.gitignore | 6 ++ .../member/controller/MemberController.java | 83 +++++++++++++++++-- .../domain/member/member/dto/MemberDto.java | 39 +++++++++ .../domain/member/member/entity/Member.java | 16 +++- .../member/repository/MemberRepository.java | 2 + .../member/member/service/MemberService.java | 26 +++++- .../CustomAuthorizationRequestResolver.java | 50 +++++++++++ .../domain/oauth/CustomOAuth2UserService.java | 44 ++++++++++ .../domain/oauth/OAuth2SuccessHandler.java | 39 +++++++++ .../domain/oauth/OAuthAttributes.java | 66 +++++++++++++++ .../domain/oauth/SecurityUser.java | 48 +++++++++++ .../nbe342team8/global/config/WebConfig.java | 11 +-- .../global/security/SecurityConfig.java | 62 +++++++++++--- backend/src/main/resources/application.yml | 37 ++++++++- 14 files changed, 502 insertions(+), 27 deletions(-) create mode 100644 backend/src/main/java/com/ll/nbe342team8/domain/member/member/dto/MemberDto.java create mode 100644 backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomAuthorizationRequestResolver.java create mode 100644 backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomOAuth2UserService.java create mode 100644 backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuth2SuccessHandler.java create mode 100644 backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuthAttributes.java create mode 100644 backend/src/main/java/com/ll/nbe342team8/domain/oauth/SecurityUser.java diff --git a/backend/.gitignore b/backend/.gitignore index 2a3ab38..f618d3b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -44,3 +44,9 @@ src/main/java/com/ll/nbe342team8/domain/member/member/dto/KakaoProfile.java src/main/java/com/ll/nbe342team8/domain/member/member/dto/KakaoTokenResponse.java src/main/java/com/ll/nbe342team8/domain/member/member/dto/KakaoUserProperties.java src/main/java/com/ll/nbe342team8/domain/member/member/dto/KakaoUserResponse.java + +# Ignore application configuration files +application.yml +application-dev.yml +application-prod.yml +application-secret.yml \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/controller/MemberController.java b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/controller/MemberController.java index a010daf..d006f38 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/controller/MemberController.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/controller/MemberController.java @@ -4,23 +4,26 @@ import com.ll.nbe342team8.domain.member.member.dto.ResMemberMyPageDto; import com.ll.nbe342team8.domain.member.member.entity.Member; import com.ll.nbe342team8.domain.member.member.service.MemberService; +import com.ll.nbe342team8.domain.oauth.SecurityUser; import com.ll.nbe342team8.global.exceptions.ServiceException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; - import java.util.Optional; +@RequestMapping("/api/auth") @RestController @RequiredArgsConstructor public class MemberController { private final MemberService memberService; + //마이페이지 데이터를 불러온다. 마이페이지는 resMemberMyPageDto 데이터를 이용해 마이페이지를 구성한다. @GetMapping("/my") public ResponseEntity getMyPage() { @@ -49,7 +52,8 @@ public ResponseEntity putMyPage(@RequestBody @Valid PutReqMemberMyPageDto put //jwt 토큰에서 id를 통해 회원정보를 찾는다. //여기선 임시로 이메일을 통해 회원정보를 찾는다. String email="rdh0427@naver.com"; - + //modifyOrJoin()`이 `String oauthId`를 요구하므로 + // `member.getOauthId()`와 `member.getEmail()`을 인자로 전달하도록 수정 Optional optionalMember = memberService.findByEmail(email); if(optionalMember.isEmpty()) { throw new ServiceException(404,"사용자를 찾을 수 없습니다.");} @@ -57,15 +61,80 @@ public ResponseEntity putMyPage(@RequestBody @Valid PutReqMemberMyPageDto put Member member=optionalMember.get(); // jwt 토큰으로 찾은 사용자 개체 갱신 - memberService.modifyMember(member,putReqMemberMyPageDto); + memberService.modifyOrJoin(member.getOauthId(), putReqMemberMyPageDto, member.getEmail()); ResMemberMyPageDto resMemberMyPageDto=new ResMemberMyPageDto(member); return ResponseEntity.status(200).body(resMemberMyPageDto); } + //아래 코드는 jwt토큰을 사용하지 않고 OAuth2 기반으로 사용자 정보를 갱신하는 코드 + + /* + @GetMapping("/me") + public ResponseEntity getCurrentUser(@AuthenticationPrincipal SecurityUser principal) { + if (principal == null) { + return ResponseEntity.ok(null); + } + + try { + String oauthId = principal.getName(); // OAuth2 provider의 user-name-attribute 값 + Member member = memberService.findByOauthId(oauthId) + .orElseThrow(() -> new ServiceException(404, "사용자를 찾을 수 없습니다.")); + return ResponseEntity.ok(new ResMemberMyPageDto(member)); + } catch (Exception e) { + return ResponseEntity.ok(null); + } + } + @GetMapping("/my") + public ResponseEntity getMyPage(@AuthenticationPrincipal SecurityUser principal) { + if (principal == null) { + return ResponseEntity.status(401).body("Unauthorized"); + } + + Optional optionalMember = memberService.findByEmail(principal.getEmail()); + + if (optionalMember.isEmpty()) { + throw new ServiceException(404, "사용자를 찾을 수 없습니다."); + } + + ResMemberMyPageDto memberMyPageDto = new ResMemberMyPageDto(optionalMember.get()); + return ResponseEntity.status(200).body(memberMyPageDto); + } + + @PutMapping("/my") + public ResponseEntity putMyPage(@AuthenticationPrincipal SecurityUser principal, + @RequestBody @Valid PutReqMemberMyPageDto putReqMemberMyPageDto) { + if (principal == null) { + return ResponseEntity.status(401).body("Unauthorized"); + } + Optional optionalMember = memberService.findByEmail(principal.getEmail()); + if (optionalMember.isEmpty()) { + throw new ServiceException(404, "사용자를 찾을 수 없습니다."); + } + Member member = optionalMember.get(); + memberService.modifyOrJoin(member.getOauthId(), putReqMemberMyPageDto, member.getEmail()); + + ResMemberMyPageDto resMemberMyPageDto = new ResMemberMyPageDto(member); + return ResponseEntity.status(200).body(resMemberMyPageDto); + }*/ + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + //현재 사용자의 세션을 무효화 + request.getSession().invalidate(); + + // 쿠키 삭제 (JSESSIONID가 있다면 삭제) + Cookie cookie = new Cookie("JSESSIONID", null); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setMaxAge(0); + response.addCookie(cookie); + + return ResponseEntity.ok("로그아웃 완료"); + } } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/dto/MemberDto.java b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/dto/MemberDto.java new file mode 100644 index 0000000..fb61a24 --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/dto/MemberDto.java @@ -0,0 +1,39 @@ +package com.ll.nbe342team8.domain.member.member.dto; + + +import com.ll.nbe342team8.domain.member.member.entity.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.Map; + +@Getter +@NoArgsConstructor +public class MemberDto { + private String oauthId; + private String name; + private String email; + private Member.MemberType memberType; + + public MemberDto(Member entity) { + this.oauthId = entity.getOauthId(); + this.name = entity.getName(); + this.email = entity.getEmail(); + this.memberType = entity.getMemberType(); + } + + public Map getAttributes() { + Map attributes = new HashMap<>(); + attributes.put("oauthId", this.oauthId); + attributes.put("name", this.name); + attributes.put("email", this.email); + attributes.put("memberType", this.memberType); + return attributes; + } + + public enum MemberType { + USER, + ADMIN + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/entity/Member.java b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/entity/Member.java index 5ac51d4..36b9c38 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/entity/Member.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/entity/Member.java @@ -9,7 +9,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -32,7 +35,7 @@ public class Member extends BaseTime { private MemberType memberType; // 사용자 역할(사용자, 관리자) @Column(name="oauth_id") - private Long oauthId; + private String oauthId; @Column(name = "email") private String email; // 소셜 로그인 ID @@ -64,4 +67,15 @@ public void convertFalseDeliveryInformaitonsIsDefaultAddress() { public void deleteDeliveryInformaiton(Long id) { deliveryInformations.removeIf(deliveryInfo -> deliveryInfo.getId().equals(id)); } + + public String getUsername() { + return oauthId; + } + public String getNickname() { + return name; + } + + public Collection getAuthorities() { + return Collections.emptyList(); + } } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/repository/MemberRepository.java b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/repository/MemberRepository.java index 792d1e2..fdfe30f 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/repository/MemberRepository.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/repository/MemberRepository.java @@ -9,4 +9,6 @@ public interface MemberRepository extends JpaRepository { Optional findByEmail(String email); Optional findByName(String name); + + Optional findByOauthId(String oauthId); } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/service/MemberService.java b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/service/MemberService.java index f38e434..a120aeb 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/member/member/service/MemberService.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/member/member/service/MemberService.java @@ -25,9 +25,25 @@ public Optional findByEmail(String email) { } @Transactional - public void modifyMember(Member member, PutReqMemberMyPageDto dto) { - //사용자 개체 데이터 갱신 - member.updateMemberInfo(dto); + public Member modifyOrJoin(String oauthId, PutReqMemberMyPageDto dto, String email) { + return memberRepository.findByOauthId(oauthId) // 기존 회원인지 확인 (oauthId 기준으로 검색) + .map(member -> { + // 기존 회원 정보 업데이트 + member.updateMemberInfo(dto); + member.setEmail(email); // 이메일 업데이트 추가 + return memberRepository.save(member); + }) + .orElseGet(() -> { + // 새 회원 생성 시 기본값으로 USER 타입 설정 + Member member = Member.builder() + .oauthId(oauthId) + .email(email) + .name(dto.getName()) + .phoneNumber(dto.getPhoneNumber() != null ? dto.getPhoneNumber() : "")//전화번호가 없으면 빈 문자열("") 저장 + .memberType(Member.MemberType.USER) + .build(); + return memberRepository.save(member); + }); } @@ -42,4 +58,8 @@ public Member create(Member member) { public long count(){ return memberRepository.count(); } + + public Optional findByOauthId(String oauthId) { + return memberRepository.findByOauthId(oauthId); + } } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomAuthorizationRequestResolver.java b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomAuthorizationRequestResolver.java new file mode 100644 index 0000000..94c5d7e --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomAuthorizationRequestResolver.java @@ -0,0 +1,50 @@ +package com.ll.nbe342team8.domain.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +@Component +public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + private final DefaultOAuth2AuthorizationRequestResolver defaultResolver; + + public CustomAuthorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) { + this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, "/oauth2/authorization"); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request); + return customizeAuthorizationRequest(authorizationRequest, request); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request, clientRegistrationId); + return customizeAuthorizationRequest(authorizationRequest, request); + } + + private OAuth2AuthorizationRequest customizeAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request) { + if (authorizationRequest == null || request == null) { + return null; + } + + String redirectUrl = request.getParameter("redirectUrl"); + + Map additionalParameters = new HashMap<>(authorizationRequest.getAdditionalParameters()); + if (redirectUrl != null && !redirectUrl.isEmpty()) { + additionalParameters.put("state", redirectUrl); + } + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters(additionalParameters) + .state(redirectUrl) + .build(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomOAuth2UserService.java b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomOAuth2UserService.java new file mode 100644 index 0000000..12975e6 --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/CustomOAuth2UserService.java @@ -0,0 +1,44 @@ +package com.ll.nbe342team8.domain.oauth; + + +import com.ll.nbe342team8.domain.member.member.dto.PutReqMemberMyPageDto; +import com.ll.nbe342team8.domain.member.member.entity.Member; +import com.ll.nbe342team8.domain.member.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final MemberService memberService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oauth2User = super.loadUser(request); + + // 안전한 타입 캐스팅과 널 체크 + Map attributes = oauth2User.getAttributes(); + Map kakaoAccount = (Map) attributes.getOrDefault("kakao_account", new HashMap<>()); + Map profile = (Map) kakaoAccount.getOrDefault("profile", new HashMap<>()); + + String oauthId = oauth2User.getName(); // kakaoId -> oauthId + String email = (String) kakaoAccount.getOrDefault("email", ""); + String name = (String) profile.getOrDefault("nickname", ""); // nickname -> name + + + PutReqMemberMyPageDto dto = new PutReqMemberMyPageDto(); + dto.setName(name); // 닉네임 설정 + dto.setPhoneNumber(""); // 기본 전화번호 설정 (빈 값) + + Member member = memberService.modifyOrJoin(oauthId, dto, email); + + return new SecurityUser(member); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuth2SuccessHandler.java b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuth2SuccessHandler.java new file mode 100644 index 0000000..813d068 --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuth2SuccessHandler.java @@ -0,0 +1,39 @@ +package com.ll.nbe342team8.domain.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + @Value("${custom.site.frontUrl}") + private String redirectUri; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + // 프론트엔드로 리다이렉트할 URL을 생성 + String targetUrl = UriComponentsBuilder.fromUriString(redirectUri) + .queryParam("login", "success") + .build().toUriString(); + + // 세션 유지를 위해 쿠키의 SameSite 속성을 설정 + response.setHeader("Set-Cookie", "JSESSIONID=" + request.getSession().getId() + + "; Path=/; HttpOnly; SameSite=Lax"); + + // 프론트엔드로 리다이렉트 + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuthAttributes.java b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuthAttributes.java new file mode 100644 index 0000000..67259da --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/OAuthAttributes.java @@ -0,0 +1,66 @@ +package com.ll.nbe342team8.domain.oauth; + +import com.ll.nbe342team8.domain.member.member.entity.Member; +import lombok.Builder; +import lombok.Getter; + +import java.util.Collections; +import java.util.Map; + +@Getter +public class OAuthAttributes { + private Map attributes; + private String nameAttributeKey; + private String oauthId; // kakaoId를 oauthId로 변경 + private String name; + private String email; // + + @Builder + public OAuthAttributes(Map attributes, + String nameAttributeKey, + String oauthId, + String name, + String email) { + this.attributes = attributes; + this.nameAttributeKey = nameAttributeKey; + this.oauthId = oauthId; + this.name = name; + this.email = email; + } + + public static OAuthAttributes of(String registrationId, + String userNameAttributeName, + Map attributes) { + // kakao_account에서 필요한 정보 추출 + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return OAuthAttributes.builder() + .name((String) profile.get("nickname")) + .email((String) kakaoAccount.get("email")) + .oauthId(String.valueOf(attributes.get("id"))) // id를 String으로 변환 + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + public Member toEntity() { + return Member.builder() + .oauthId(oauthId) + .name(name) + .email(email) + .phoneNumber("") + .memberType(Member.MemberType.USER) + .deliveryInformations(Collections.emptyList()) + .build(); + } + + // OAuth2User의 attributes를 만들기 위한 메소드 추가 + public Map getAttributes() { + return Map.of( + "id", oauthId, + "name", name, + "email", email + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/oauth/SecurityUser.java b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/SecurityUser.java new file mode 100644 index 0000000..5a2de86 --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/domain/oauth/SecurityUser.java @@ -0,0 +1,48 @@ +package com.ll.nbe342team8.domain.oauth; + +import com.ll.nbe342team8.domain.member.member.entity.Member; +import lombok.Getter; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Map; + + +@Getter +public class SecurityUser extends User implements OAuth2User { + private final long id; + private final String nickname; + private final String email; + private Member member; + + public SecurityUser(Member member) { + super( + member.getUsername(), // oauthId를 username으로 사용 + "", // 비밀번호는 빈 문자열 + member.getAuthorities() // Member에서 정의한 권한 사용 + ); + this.id = member.getId(); + this.nickname = member.getName(); + this.email = member.getEmail(); // email 필드명 주의 + this.member = member; + } + + public Member getMember() { + return this.member; + } + + @Override + public Map getAttributes() { + return Map.of( + "id", id, + "nickname", nickname, + "email", email, + "memberType", member.getMemberType() + ); + } + + @Override + public String getName() { + return getUsername(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/global/config/WebConfig.java b/backend/src/main/java/com/ll/nbe342team8/global/config/WebConfig.java index 9dd5b3b..de34da7 100644 --- a/backend/src/main/java/com/ll/nbe342team8/global/config/WebConfig.java +++ b/backend/src/main/java/com/ll/nbe342team8/global/config/WebConfig.java @@ -8,10 +8,11 @@ public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") // 모든 요청에 대해 - .allowedOrigins("http://localhost:3000") // 허용할 출처 - .allowedMethods("GET", "POST", "PUT", "DELETE") // 허용할 HTTP 메서드 - .allowedHeaders("*") // 모든 헤더를 허용 - .allowCredentials(true); // 인증 정보 포함 허용 + registry.addMapping("/**") + .allowedOrigins("http://localhost:3000") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); } } diff --git a/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java b/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java index 5bc8fdc..76e2dbc 100644 --- a/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ll/nbe342team8/global/security/SecurityConfig.java @@ -1,30 +1,34 @@ package com.ll.nbe342team8.global.security; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; +import com.ll.nbe342team8.domain.oauth.CustomOAuth2UserService; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.core.Authentication; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.Collections; +import java.io.IOException; import java.util.List; @Configuration @EnableWebSecurity +@RequiredArgsConstructor @EnableMethodSecurity(prePostEnabled = true) // @PreAuthorize 사용 public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -39,12 +43,50 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .headers((headers) -> headers .addHeaderWriter(new XFrameOptionsHeaderWriter( XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler()) + ) .logout(logout -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/") // 로그아웃 성공 후 리다이렉트 + .logoutUrl("/api/auth/logout") // + .logoutSuccessUrl("/") // + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID") ) ; return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public AuthenticationSuccessHandler oAuth2SuccessHandler() { + return new SimpleUrlAuthenticationSuccessHandler() { + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + String targetUrl = "http://localhost:3000"; + + // ✅ 세션 유지를 위해 쿠키 설정 + response.setHeader("Set-Cookie", "JSESSIONID=" + request.getSession().getId() + + "; Path=/; HttpOnly; SameSite=Lax"); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + }; + } } \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4e70b02..f9bd97e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,8 +1,9 @@ +server: + port: 8080 spring: profiles: active: dev datasource: - url: jdbc:mysql://localhost:3306/test_db username: root password: @@ -33,6 +34,40 @@ spring: console: enabled: true # H2 콘솔 활성화 path: /h2-console # H2 콘솔 경로 (기본값: /h2-console) + security: + oauth2: + client: + registration: + kakao: + clientId: ${KAKAO_CLIENT_ID} + scope: + - profile_nickname + - profile_image + - account_email + client-name: Kakao + authorization-grant-type: authorization_code + redirect-uri: "${custom.site.backUrl}/{action}/oauth2/code/{registrationId}" + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id +custom: + dev: + cookieDomain: localhost + frontUrl: "http://${custom.dev.cookieDomain}:3000" + backUrl: "http://${custom.dev.cookieDomain}:${server.port}" + prod: + cookieDomain: book.oa.gg + frontUrl: "https://www.${custom.prod.cookieDomain}" + backUrl: "https://api.${custom.prod.cookieDomain}" + site: + cookieDomain: "${custom.dev.cookieDomain}" + frontUrl: "${custom.dev.frontUrl}" + backUrl: "${custom.dev.backUrl}" + name: Book + aladin: ttbkey: From 3111935a66106da70c7f4c13f8c3f01c4e110468 Mon Sep 17 00:00:00 2001 From: seng Date: Wed, 5 Feb 2025 12:45:36 +0900 Subject: [PATCH 09/89] =?UTF-8?q?fix:=20=EC=A3=BC=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B0=8F=20=EB=B0=B0=EC=86=A1=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ll/nbe342team8/domain/order/data/DataInitializer.java | 8 ++++---- .../domain/order/detailOrder/entity/DetailOrder.java | 5 ++++- .../ll/nbe342team8/domain/order/order/entity/Order.java | 6 +++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java index fbf108c..508d6b9 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/data/DataInitializer.java @@ -72,7 +72,7 @@ public void init() { // member2에게 3개의 주문 추가 Order order4 = new Order(member2, Order.OrderStatus.ORDERED, 5000); - Order order5 = new Order(member2, Order.OrderStatus.DELIVERY, 3200); + Order order5 = new Order(member2, Order.OrderStatus.CANCELLED, 3200); Order order6 = new Order(member2, Order.OrderStatus.COMPLETE, 1500); orderRepository.save(order4); @@ -85,12 +85,12 @@ public void init() { // 기존 주문에 DetailOrder 추가 DetailOrder detailOrder1 = new DetailOrder(order1, book1, 2, DetailOrder.DeliveryStatus.PENDING); - DetailOrder detailOrder2 = new DetailOrder(order2, book2, 3, DetailOrder.DeliveryStatus.PENDING); - DetailOrder detailOrder3 = new DetailOrder(order3, book1, 1, DetailOrder.DeliveryStatus.PENDING); + DetailOrder detailOrder2 = new DetailOrder(order2, book2, 3, DetailOrder.DeliveryStatus.RETURNED); + DetailOrder detailOrder3 = new DetailOrder(order3, book1, 1, DetailOrder.DeliveryStatus. SHIPPING); // member2의 추가된 주문에 DetailOrder 추가 DetailOrder detailOrder4 = new DetailOrder(order4, book2, 2, DetailOrder.DeliveryStatus.PENDING); - DetailOrder detailOrder5 = new DetailOrder(order5, book1, 1, DetailOrder.DeliveryStatus.SHIPPED); + DetailOrder detailOrder5 = new DetailOrder(order5, book1, 1, DetailOrder.DeliveryStatus.SHIPPING); DetailOrder detailOrder6 = new DetailOrder(order6, book2, 2, DetailOrder.DeliveryStatus.DELIVERED); detailOrderRepository.save(detailOrder1); diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/entity/DetailOrder.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/entity/DetailOrder.java index f9991f9..74b78e1 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/entity/DetailOrder.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/detailOrder/entity/DetailOrder.java @@ -29,6 +29,9 @@ public class DetailOrder extends BaseTime { private DeliveryStatus deliveryStatus; public enum DeliveryStatus { - PENDING, SHIPPED, DELIVERED + PENDING, //대기중 + SHIPPING, //배송중 + DELIVERED,//배송완료 + RETURNED//반품 } } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java index 3a831c3..5719cb6 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/order/order/entity/Order.java @@ -27,9 +27,9 @@ public class Order extends BaseTime { private long totalPrice; public enum OrderStatus{ - ORDERED, - DELIVERY, - COMPLETE + ORDERED,//주문됨 + CANCELLED,//주문 취소됨 + COMPLETE //주문 완료됨(배송까지) } } \ No newline at end of file From e1cc7b6525fab00ae9cd6b55fa5d6d11e1a6b856 Mon Sep 17 00:00:00 2001 From: rOyalFruit Date: Wed, 5 Feb 2025 14:31:56 +0900 Subject: [PATCH 10/89] =?UTF-8?q?Feat:=20openapi-typescript=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=9A=A9=ED=95=B4=EC=84=9C=20=ED=86=B5=EC=8B=A0?= =?UTF-8?q?=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Profile("dev")로 dev 모드일 때만 실행됨 - ./frontend/types/schema.d.ts 경로에 생성됨 --- .gitignore | 3 +- .../entity => initData}/BaseInitData.java | 3 +- .../global/initData/DevInitData.java | 31 + .../{config => springDoc}/SwaggerConfig.java | 2 +- .../com/ll/nbe342team8/standard/util/Ut.java | 141 ++- .../src/main/resources/application-dev.yml | 0 .../src/main/resources/application-test.yml | 3 + backend/src/main/resources/application.yml | 25 + frontend/types/schema.d.ts | 861 ++++++++++++++++++ 9 files changed, 1064 insertions(+), 5 deletions(-) rename backend/src/main/java/com/ll/nbe342team8/global/{jpa/entity => initData}/BaseInitData.java (98%) create mode 100644 backend/src/main/java/com/ll/nbe342team8/global/initData/DevInitData.java rename backend/src/main/java/com/ll/nbe342team8/global/{config => springDoc}/SwaggerConfig.java (95%) create mode 100644 backend/src/main/resources/application-dev.yml create mode 100644 backend/src/main/resources/application-test.yml create mode 100644 backend/src/main/resources/application.yml create mode 100644 frontend/types/schema.d.ts diff --git a/.gitignore b/.gitignore index 67fe74d..665a501 100644 --- a/.gitignore +++ b/.gitignore @@ -205,5 +205,6 @@ gradle-app.setting .idea/ db_dev.mv.db -application.yml +db_dev.trace.db +api-docs.json # End of https://www.toptal.com/developers/gitignore/api/nextjs,intellij,java,gradle \ No newline at end of file diff --git a/backend/src/main/java/com/ll/nbe342team8/global/jpa/entity/BaseInitData.java b/backend/src/main/java/com/ll/nbe342team8/global/initData/BaseInitData.java similarity index 98% rename from backend/src/main/java/com/ll/nbe342team8/global/jpa/entity/BaseInitData.java rename to backend/src/main/java/com/ll/nbe342team8/global/initData/BaseInitData.java index 6e523d9..0a52174 100644 --- a/backend/src/main/java/com/ll/nbe342team8/global/jpa/entity/BaseInitData.java +++ b/backend/src/main/java/com/ll/nbe342team8/global/initData/BaseInitData.java @@ -1,4 +1,4 @@ -package com.ll.nbe342team8.global.jpa.entity; +package com.ll.nbe342team8.global.initData; import com.ll.nbe342team8.domain.book.book.entity.Book; import com.ll.nbe342team8.domain.book.book.service.BookService; @@ -18,7 +18,6 @@ import java.io.IOException; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import java.util.Random; diff --git a/backend/src/main/java/com/ll/nbe342team8/global/initData/DevInitData.java b/backend/src/main/java/com/ll/nbe342team8/global/initData/DevInitData.java new file mode 100644 index 0000000..a2ed40f --- /dev/null +++ b/backend/src/main/java/com/ll/nbe342team8/global/initData/DevInitData.java @@ -0,0 +1,31 @@ +package com.ll.nbe342team8.global.initData; + +import com.ll.nbe342team8.domain.member.member.service.MemberService; +import com.ll.nbe342team8.standard.util.Ut; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.annotation.Profile; + +@Profile("dev") +@Configuration +@RequiredArgsConstructor +public class DevInitData { + + @Autowired + @Lazy + private DevInitData self; + + @Bean + public ApplicationRunner devInitDataApplicationRunner() { + return args -> { + Ut.file.downloadByHttp("http://localhost:8080/v3/api-docs", "."); + + String cmd = "yes | npx --package typescript --package openapi-typescript --package punycode openapi-typescript api-docs.json -o ./frontend/types/schema.d.ts"; + Ut.cmd.runAsync(cmd); + }; + } +} diff --git a/backend/src/main/java/com/ll/nbe342team8/global/config/SwaggerConfig.java b/backend/src/main/java/com/ll/nbe342team8/global/springDoc/SwaggerConfig.java similarity index 95% rename from backend/src/main/java/com/ll/nbe342team8/global/config/SwaggerConfig.java rename to backend/src/main/java/com/ll/nbe342team8/global/springDoc/SwaggerConfig.java index 3418bfd..03e2968 100644 --- a/backend/src/main/java/com/ll/nbe342team8/global/config/SwaggerConfig.java +++ b/backend/src/main/java/com/ll/nbe342team8/global/springDoc/SwaggerConfig.java @@ -1,4 +1,4 @@ -package com.ll.nbe342team8.global.config; +package com.ll.nbe342team8.global.springDoc; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; diff --git a/backend/src/main/java/com/ll/nbe342team8/standard/util/Ut.java b/backend/src/main/java/com/ll/nbe342team8/standard/util/Ut.java index c83274c..dc86c46 100644 --- a/backend/src/main/java/com/ll/nbe342team8/standard/util/Ut.java +++ b/backend/src/main/java/com/ll/nbe342team8/standard/util/Ut.java @@ -3,10 +3,23 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; +import javax.crypto.SecretKey; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.TimeUnit; public class Ut { - public static class str { public static boolean isBlank(String str) { return str == null || str.trim().isEmpty(); @@ -21,4 +34,130 @@ public static String toString(Object obj) { return om.writeValueAsString(obj); } } + + public static class file { + + public static void downloadByHttp(String url, String dirPath) { + try { + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .GET() + .build(); + + // 먼저 헤더만 가져오기 위한 HEAD 요청 + HttpResponse headResponse = client.send( + HttpRequest.newBuilder(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.discarding() + ); + + // 실제 파일 다운로드 + HttpResponse response = client.send(request, + HttpResponse.BodyHandlers.ofFile( + createTargetPath(url, dirPath, headResponse) + )); + } catch (IOException | InterruptedException e) { + throw new RuntimeException("다운로드 중 오류 발생: " + e.getMessage(), e); + } + } + + private static Path createTargetPath(String url, String dirPath, HttpResponse response) { + // 디렉토리가 없으면 생성 + Path directory = Path.of(dirPath); + if (!Files.exists(directory)) { + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new RuntimeException("디렉토리 생성 실패: " + e.getMessage(), e); + } + } + + // 파일명 생성 + String filename = getFilenameFromUrl(url); + String extension = getExtensionFromResponse(response); + + return directory.resolve(filename + extension); + } + + private static String getFilenameFromUrl(String url) { + try { + String path = new URI(url).getPath(); + String filename = Path.of(path).getFileName().toString(); + // 확장자 제거 + return filename.contains(".") + ? filename.substring(0, filename.lastIndexOf('.')) + : filename; + } catch (URISyntaxException e) { + // URL에서 파일명을 추출할 수 없는 경우 타임스탬프 사용 + return "download_" + System.currentTimeMillis(); + } + } + + private static String getExtensionFromResponse(HttpResponse response) { + return response.headers() + .firstValue("Content-Type") + .map(contentType -> { + // MIME 타입에 따른 확장자 매핑 + return switch (contentType.split(";")[0].trim().toLowerCase()) { + case "application/json" -> ".json"; + case "text/plain" -> ".txt"; + case "text/html" -> ".html"; + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "application/pdf" -> ".pdf"; + case "application/xml" -> ".xml"; + case "application/zip" -> ".zip"; + default -> ""; + }; + }) + .orElse(""); + } + } + + public class cmd { + public static void runAsync(String cmd) { + new Thread(() -> { + run(cmd); + }).start(); + } + + // public static void run(String cmd) { +// try { +// ProcessBuilder processBuilder = new ProcessBuilder("bash", "-c", cmd); +// Process process = processBuilder.start(); +// process.waitFor(1, TimeUnit.MINUTES); +// System.out.println("정상@@@".repeat(200)); +// } catch (Exception e) { +// e.printStackTrace(); +// } +// } + public static void run(String cmd) { + try { + ProcessBuilder pb = new ProcessBuilder("bash", "-c", cmd); + pb.redirectErrorStream(true); // 에러 출력 리다이렉트 + + Process process = pb.start(); + + // 출력 내용 읽기 + BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream()) + ); + String line; + while ((line = reader.readLine()) != null) { + System.out.println("[CMD] " + line); // 로그 추가 + } + + int exitCode = process.waitFor(); + System.out.println("Exit Code: " + exitCode); + + } catch (Exception e) { + e.printStackTrace(); + } + } + } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/main/resources/application-test.yml b/backend/src/main/resources/application-test.yml new file mode 100644 index 0000000..f405193 --- /dev/null +++ b/backend/src/main/resources/application-test.yml @@ -0,0 +1,3 @@ +spring: + datasource: + url: jdbc:h2:mem:db_test;MODE=MySQL \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..75a2a55 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,25 @@ +spring: + profiles: + active: dev + datasource: + url: jdbc:h2:./db_dev;MODE=MySQL + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + highlight-sql: true + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + show-sql: true + +springdoc: + default-produces-media-type: application/json;charset=UTF-8 + +aladin: + ttbkey: ${TTBKEY} \ No newline at end of file diff --git a/frontend/types/schema.d.ts b/frontend/types/schema.d.ts new file mode 100644 index 0000000..556d390 --- /dev/null +++ b/frontend/types/schema.d.ts @@ -0,0 +1,861 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/reviews/{review-id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 리뷰 수정 */ + put: operations["updateReview"]; + post?: never; + /** 리뷰 삭제 */ + delete: operations["deleteReview"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cart/{book-id}/{member-id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 장바구니 수정 */ + put: operations["updateCartItem"]; + /** 장바구니 추가 */ + post: operations["addCart"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/reviews/{book-id}/{member-id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 리뷰 등록 */ + post: operations["createReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cart/{member-id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 장바구니 조회 */ + get: operations["getCart"]; + put?: never; + /** 장바구니 수정 json */ + post: operations["updateCartItems"]; + /** 장바구니 삭제 */ + delete: operations["deleteBook"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/books/admin/books": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["addBook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/books/admin/books/{bookId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateBookPart"]; + trace?: never; + }; + "/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 전체 리뷰 조회 */ + get: operations["getAllReviews"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/reviews/{book-id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 특정 도서 리뷰 조회 */ + get: operations["getReviewsById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/event/banners": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getBannerImages"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/books": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 전체 도서 조회 */ + get: operations["getAllBooks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/books/{book-id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 특정 도서 조회 */ + get: operations["getBookById"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/books/{book-id}/review": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 특정 도서 댓글 조회 */ + get: operations["getBookReview"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/books/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 도서 이름 검색 */ + get: operations["searchBooks"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Book: { + /** Format: int64 */ + id?: number; + /** Format: date-time */ + createDate?: string; + /** Format: date-time */ + modifyDate?: string; + title: string; + author: string; + isbn?: string; + isbn13: string; + /** Format: date */ + pubDate: string; + /** Format: int32 */ + priceStandard: number; + /** Format: int32 */ + pricesSales: number; + /** Format: int32 */ + stock: number; + /** Format: int32 */ + status: number; + /** Format: float */ + rating?: number; + toc?: string; + coverImage?: string; + description?: string; + descriptionImage?: string; + /** Format: int64 */ + salesPoint?: number; + /** Format: int64 */ + reviewCount?: number; + publisher?: string; + review?: components["schemas"]["Review"][]; + }; + Cart: { + /** Format: int64 */ + id?: number; + /** Format: int32 */ + quantity?: number; + }; + Member: { + /** Format: int64 */ + id?: number; + /** Format: date-time */ + createDate?: string; + /** Format: date-time */ + modifyDate?: string; + name?: string; + phoneNumber?: string; + /** @enum {string} */ + memberType?: "USER" | "ADMIN"; + /** Format: int64 */ + oauthId?: number; + review?: components["schemas"]["Review"][]; + cart?: components["schemas"]["Cart"][]; + }; + Review: { + /** Format: int64 */ + id?: number; + /** Format: date-time */ + createDate?: string; + /** Format: date-time */ + modifyDate?: string; + book?: components["schemas"]["Book"]; + member?: components["schemas"]["Member"]; + content?: string; + /** Format: float */ + rating?: number; + }; + CartItemRequestDto: { + /** Format: int64 */ + bookId?: number; + /** Format: int32 */ + quantity?: number; + }; + CartRequestDto: { + cartItems?: components["schemas"]["CartItemRequestDto"][]; + }; + BookPatchRequestDto: { + title?: string; + author?: string; + isbn?: string; + isbn13?: string; + /** Format: date */ + pubDate?: string; + /** Format: int32 */ + priceStandard?: number; + /** Format: int32 */ + priceSales?: number; + /** Format: int32 */ + stock?: number; + /** Format: int32 */ + status?: number; + /** Format: float */ + rating?: number; + toc?: string; + cover?: string; + description?: string; + descriptionImage?: string; + categoryId?: components["schemas"]["Category"]; + validStatus?: boolean; + }; + Category: { + /** Format: int64 */ + id?: number; + /** Format: int32 */ + categoryId: number; + categoryName: string; + mall: string; + depth1: string; + depth2?: string; + depth3?: string; + depth4?: string; + depth5?: string; + books?: components["schemas"]["Book"][]; + category?: string; + }; + BookResponseDto: { + /** Format: int64 */ + id?: number; + title?: string; + author?: string; + isbn?: string; + isbn13?: string; + publisher?: string; + /** Format: date */ + pubDate?: string; + /** Format: int32 */ + priceStandard?: number; + /** Format: int32 */ + priceSales?: number; + /** Format: int64 */ + salesPoint?: number; + /** Format: int32 */ + stock?: number; + /** Format: int32 */ + status?: number; + /** Format: float */ + rating?: number; + toc?: string; + /** Format: int64 */ + reviewCount?: number; + coverImage?: string; + /** Format: int32 */ + categoryId?: number; + description?: string; + descriptionImage?: string; + }; + PageReviewResponseDto: { + /** Format: int64 */ + totalElements?: number; + /** Format: int32 */ + totalPages?: number; + /** Format: int32 */ + size?: number; + content?: components["schemas"]["ReviewResponseDto"][]; + /** Format: int32 */ + number?: number; + sort?: components["schemas"]["SortObject"]; + first?: boolean; + last?: boolean; + /** Format: int32 */ + numberOfElements?: number; + pageable?: components["schemas"]["PageableObject"]; + empty?: boolean; + }; + PageableObject: { + /** Format: int64 */ + offset?: number; + sort?: components["schemas"]["SortObject"]; + /** Format: int32 */ + pageNumber?: number; + /** Format: int32 */ + pageSize?: number; + paged?: boolean; + unpaged?: boolean; + }; + ReviewResponseDto: { + /** Format: int64 */ + bookId?: number; + /** Format: int64 */ + reviewId?: number; + author?: string; + content?: string; + /** Format: float */ + rating?: number; + /** Format: date-time */ + createDate?: string; + }; + SortObject: { + empty?: boolean; + sorted?: boolean; + unsorted?: boolean; + }; + CartResponseDto: { + /** Format: int64 */ + memberId?: number; + /** Format: int64 */ + bookId?: number; + /** Format: int32 */ + quantity?: number; + title?: string; + /** Format: int32 */ + price?: number; + coverImage?: string; + }; + PageBookResponseDto: { + /** Format: int64 */ + totalElements?: number; + /** Format: int32 */ + totalPages?: number; + /** Format: int32 */ + size?: number; + content?: components["schemas"]["BookResponseDto"][]; + /** Format: int32 */ + number?: number; + sort?: components["schemas"]["SortObject"]; + first?: boolean; + last?: boolean; + /** Format: int32 */ + numberOfElements?: number; + pageable?: components["schemas"]["PageableObject"]; + empty?: boolean; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + updateReview: { + parameters: { + query: { + content: string; + rating: number; + }; + header?: never; + path: { + "review-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteReview: { + parameters: { + query?: never; + header?: never; + path: { + "review-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateCartItem: { + parameters: { + query: { + quantity: number; + }; + header?: never; + path: { + "book-id": number; + "member-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addCart: { + parameters: { + query?: never; + header?: never; + path: { + "book-id": number; + "member-id": number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CartItemRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createReview: { + parameters: { + query?: never; + header?: never; + path: { + "book-id": number; + "member-id": number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Review"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getCart: { + parameters: { + query?: never; + header?: never; + path: { + "member-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["CartResponseDto"][]; + }; + }; + }; + }; + updateCartItems: { + parameters: { + query?: never; + header?: never; + path: { + "member-id": number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CartRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteBook: { + parameters: { + query?: never; + header?: never; + path: { + "member-id": number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CartRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addBook: { + parameters: { + query?: { + isbn13?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": Record; + }; + }; + }; + }; + updateBookPart: { + parameters: { + query?: never; + header?: never; + path: { + bookId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["BookPatchRequestDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["BookResponseDto"]; + }; + }; + }; + }; + getAllReviews: { + parameters: { + query?: { + page?: number; + pageSize?: number; + sortType?: "CREATE_AT_DESC" | "CREATE_AT_ASC" | "RATING_DESC" | "RATING_ASC"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["PageReviewResponseDto"]; + }; + }; + }; + }; + getReviewsById: { + parameters: { + query?: { + page?: number; + pageSize?: number; + sortType?: "CREATE_AT_DESC" | "CREATE_AT_ASC" | "RATING_DESC" | "RATING_ASC"; + }; + header?: never; + path: { + "book-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["PageReviewResponseDto"]; + }; + }; + }; + }; + getBannerImages: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": string[]; + }; + }; + }; + }; + getAllBooks: { + parameters: { + query?: { + page?: number; + pageSize?: number; + sortType?: "PUBLISHED_DATE" | "SALES_POINT" | "RATING" | "REVIEW_COUNT"; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["PageBookResponseDto"]; + }; + }; + }; + }; + getBookById: { + parameters: { + query?: never; + header?: never; + path: { + "book-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["BookResponseDto"]; + }; + }; + }; + }; + getBookReview: { + parameters: { + query?: never; + header?: never; + path: { + "book-id": number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + searchBooks: { + parameters: { + query: { + page?: number; + pageSize?: number; + sortType?: "PUBLISHED_DATE" | "SALES_POINT" | "RATING" | "REVIEW_COUNT"; + title: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json;charset=UTF-8": components["schemas"]["PageBookResponseDto"]; + }; + }; + }; + }; +} From 65510af5d4e32dce074edae0df218a6eccae100b Mon Sep 17 00:00:00 2001 From: 1m1nkim Date: Wed, 5 Feb 2025 14:46:35 +0900 Subject: [PATCH 11/89] =?UTF-8?q?Feat:=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80,=20=EB=B3=84=EC=A0=90?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출간일순, 판매량순, 평점순, 리뷰순 정렬 - 평점순은 총점인 db로는 구현하지 못해서 averageRating 추가 - 정렬 시 판매량인 경우 판매량이 같거나 없는경우 출간일 순으로 보조 정렬, 다른 것도 같음 --- .../domain/book/book/dto/BookResponseDto.java | 54 ++++++++--------- .../domain/book/book/entity/Book.java | 4 ++ .../domain/book/book/service/BookService.java | 15 +++-- .../domain/book/book/type/SortType.java | 2 +- frontend/app/search/components/BookCard.tsx | 57 ++++++++++++++++++ frontend/app/search/components/BookGrid.tsx | 22 +++++++ .../search/components/SearchResultItem.tsx | 33 ---------- frontend/app/search/components/SortBar.tsx | 37 ++++++++++++ frontend/app/search/components/StarRating.tsx | 58 ++++++++++++++++++ frontend/app/search/page.tsx | 60 ++++++++++++------- frontend/types/book.ts | 1 + 11 files changed, 253 insertions(+), 90 deletions(-) create mode 100644 frontend/app/search/components/BookCard.tsx create mode 100644 frontend/app/search/components/BookGrid.tsx delete mode 100644 frontend/app/search/components/SearchResultItem.tsx create mode 100644 frontend/app/search/components/SortBar.tsx create mode 100644 frontend/app/search/components/StarRating.tsx diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/dto/BookResponseDto.java b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/dto/BookResponseDto.java index f90d7bc..4f5545f 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/dto/BookResponseDto.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/dto/BookResponseDto.java @@ -1,36 +1,31 @@ package com.ll.nbe342team8.domain.book.book.dto; import com.ll.nbe342team8.domain.book.book.entity.Book; -import com.ll.nbe342team8.domain.book.category.entity.Category; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - import java.time.LocalDate; -import java.time.LocalDateTime; - -public record BookResponseDto(Long id, - String title, - String author, - String isbn, - String isbn13, - String publisher, - LocalDate pubDate, - int priceStandard, - int priceSales, - long salesPoint, - int stock, - int status, - float rating, - String toc, - long reviewCount, - String coverImage, - Integer categoryId, - String description, - String descriptionImage) { - public static BookResponseDto from(Book book){ +public record BookResponseDto( + Long id, + String title, + String author, + String isbn, + String isbn13, + String publisher, + LocalDate pubDate, + int priceStandard, + int priceSales, + long salesPoint, + int stock, + int status, + float rating, + String toc, + long reviewCount, + String coverImage, + Integer categoryId, + String description, + String descriptionImage, + float averageRating // 추가한 평균 평점 필드 +) { + public static BookResponseDto from(Book book) { return new BookResponseDto( book.getId(), book.getTitle(), @@ -50,7 +45,8 @@ public static BookResponseDto from(Book book){ book.getCoverImage(), book.getCategoryId().getCategoryId(), book.getDescription(), - book.getDescriptionImage() + book.getDescriptionImage(), + book.getAverageRating() ); } } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/entity/Book.java b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/entity/Book.java index c8eff41..8ec082c 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/entity/Book.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/entity/Book.java @@ -11,6 +11,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.Formula; import java.time.LocalDate; import java.util.List; @@ -52,6 +53,9 @@ public class Book extends BaseTime { private float rating; // 평점 + @Formula("CASE WHEN review_count = 0 THEN 0 ELSE rating / review_count END") + private float averageRating; //평균 평점 + private String toc; // 목차 private String coverImage; // 커버 이미지 URL diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/service/BookService.java b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/service/BookService.java index d2e9f26..fad8ce9 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/service/BookService.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/service/BookService.java @@ -61,10 +61,17 @@ public Book deleteReview(Book book, float rating) { } public Page searchBooks(int page, int pageSize, SortType sortType, String title) { - List sorts = new ArrayList<>(); - sorts.add(sortType.getOrder()); - - Pageable pageable = PageRequest.of(page, pageSize, Sort.by(sorts)); + Pageable pageable; + // 출간일을 보조 정렬 기준으로 항상 적용하려면, 판매량, 평점, 리뷰 정렬 시 복합 정렬 조건을 사용 + if (sortType == SortType.SALES_POINT || sortType == SortType.RATING || sortType == SortType.REVIEW_COUNT) { + pageable = PageRequest.of(page, pageSize, Sort.by( + new Sort.Order(sortType.getOrder().getDirection(), sortType.getOrder().getProperty()), + new Sort.Order(Sort.Direction.DESC, "pubDate") + )); + } else { + // 기본적으로 출간일 순인 경우엔 그냥 해당 정렬을 사용 + pageable = PageRequest.of(page, pageSize, Sort.by(sortType.getOrder())); + } return bookRepository.findBooksByTitleContaining(title, pageable); } diff --git a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/type/SortType.java b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/type/SortType.java index e6e7b25..ec8b144 100644 --- a/backend/src/main/java/com/ll/nbe342team8/domain/book/book/type/SortType.java +++ b/backend/src/main/java/com/ll/nbe342team8/domain/book/book/type/SortType.java @@ -5,7 +5,7 @@ public enum SortType { PUBLISHED_DATE("pubDate", Sort.Direction.DESC), // 출간일순 SALES_POINT("salesPoint", Sort.Direction.ASC), // 판매량순 - RATING("rating", Sort.Direction.DESC), // 평점순 + RATING("averageRating", Sort.Direction.DESC), // 평점순 REVIEW_COUNT("reviewCount", Sort.Direction.DESC); // 리뷰 많은순 private final String field; diff --git a/frontend/app/search/components/BookCard.tsx b/frontend/app/search/components/BookCard.tsx new file mode 100644 index 0000000..6b25ba2 --- /dev/null +++ b/frontend/app/search/components/BookCard.tsx @@ -0,0 +1,57 @@ +"use client"; +import React from "react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { Book } from "@/types/book"; +import StarRating from "./StarRating"; + +const BookCard: React.FC<{ book: Book }> = ({ book }) => { + const router = useRouter(); + + const handleClick = () => { + router.push(`/books/${book.id}`); + }; + + return ( +
+ {/* 이미지 영역: 가로 비율을 1.5배 넓게 하기 위해 aspect-[3/2] 적용 */} +
+ {book.title} +
+

{book.title}

+

저자: {book.author}

+

출판사: {book.publisher}

+

+ 정가: {book.priceStandard.toLocaleString()}원 +

+

+ 판매가: {book.priceSales.toLocaleString()}원 +

+ {/* 평균 평점 및 리뷰 개수 표시 */} +

+ 평점: + + {book.averageRating ? book.averageRating.toFixed(1) : "평점 없음"} + + ({book.reviewCount}) +

+ {/* 별점 아이콘 표시 */} +
+ +
+

+ {book.description || "설명 없음"} +

+
+ ); +}; + +export default BookCard; diff --git a/frontend/app/search/components/BookGrid.tsx b/frontend/app/search/components/BookGrid.tsx new file mode 100644 index 0000000..7f5fd27 --- /dev/null +++ b/frontend/app/search/components/BookGrid.tsx @@ -0,0 +1,22 @@ +"use client"; +import React from "react"; +import { Book } from "@/types/book"; +import BookCard from "./BookCard"; + +interface BookGridProps { + books: Book[]; +} + +const BookGrid: React.FC = ({ books }) => { + return ( +
+ {books.map((book) => ( +
+ +
+ ))} +
+ ); +}; + +export default BookGrid; diff --git a/frontend/app/search/components/SearchResultItem.tsx b/frontend/app/search/components/SearchResultItem.tsx deleted file mode 100644 index 7d10413..0000000 --- a/frontend/app/search/components/SearchResultItem.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; -import React from 'react'; -import Image from 'next/image'; -import { Book } from '@/types/book'; - -interface SearchResultItemProps { - book: Book; -} - -const SearchResultItem: React.FC = ({ book }) => { - return ( -
- {/* 책 이미지 영역 */} -
- {book.title} -
- - {/* 책 정보 영역 */} -
-

{book.title}

-

가격: {book.priceSales.toLocaleString()}원

-

{book.description || '책 설명이 준비 중입니다.'}

-
-
- ); -}; - -export default SearchResultItem; diff --git a/frontend/app/search/components/SortBar.tsx b/frontend/app/search/components/SortBar.tsx new file mode 100644 index 0000000..019cf6b --- /dev/null +++ b/frontend/app/search/components/SortBar.tsx @@ -0,0 +1,37 @@ +"use client"; +import React from "react"; + +interface SortBarProps { + currentSort: string; + onSortChange: (newSort: string) => void; +} + +const sortOptions = [ + { label: "출간일순", value: "PUBLISHED_DATE" }, + { label: "판매량순", value: "SALES_POINT" }, + { label: "평점순", value: "RATING" }, + { label: "리뷰 많은 순", value: "REVIEW_COUNT" }, +]; + +export const SortBar: React.FC = ({ currentSort, onSortChange }) => { + return ( +
+
+ {sortOptions.map((option) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/app/search/components/StarRating.tsx b/frontend/app/search/components/StarRating.tsx new file mode 100644 index 0000000..e238270 --- /dev/null +++ b/frontend/app/search/components/StarRating.tsx @@ -0,0 +1,58 @@ +"use client"; +import React from "react"; + +interface StarRatingProps { + rating: number; +} + +const StarRating: React.FC = ({ rating }) => { + const validRating = rating || 0; + const fullStars = Math.floor(validRating); + const halfStar = validRating - fullStars >= 0.5; + const emptyStars = 5 - fullStars - (halfStar ? 1 : 0); + + return ( +
+ {Array.from({ length: fullStars }).map((_, idx) => ( + + + + ))} + {halfStar && ( + + + + {/* 노란색 */} + {/* 회색 */} + + + + + )} + {Array.from({ length: emptyStars }).map((_, idx) => ( + + + + ))} +
+ ); +}; + +export default StarRating; diff --git a/frontend/app/search/page.tsx b/frontend/app/search/page.tsx index 63874b7..161aa6e 100644 --- a/frontend/app/search/page.tsx +++ b/frontend/app/search/page.tsx @@ -1,34 +1,36 @@ "use client"; import React, { useState, useEffect } from "react"; -import { useSearchParams } from "next/navigation"; -import SearchResultItem from "./components/SearchResultItem"; -import { fetchSearchBooks } from "@/utils/api.js"; - -interface Book { - id: number; - title: string; - price: number; - coverImage: string; - description: string; -} +import { useSearchParams, useRouter } from "next/navigation"; +import BookGrid from "./components/BookGrid"; +import { fetchSearchBooks } from "@/utils/api"; +import { Book } from "@/types/book"; +import { Pagination } from "@/app/components/common/Pagination"; +import { SortBar } from "./components/SortBar"; export default function SearchPage() { - // URL 쿼리 파라미터에서 title 값을 읽어옴 (예: /books/search?title=김한민) + // URL 쿼리 파라미터에서 title와 sort 값을 읽어옴 const searchParams = useSearchParams(); + const router = useRouter(); const titleParam = searchParams.get("title") || ""; + const initialSort = searchParams.get("sort") || "PUBLISHED_DATE"; + const [books, setBooks] = useState([]); const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [sortType, setSortType] = useState(initialSort); + + const pageSize = 12; useEffect(() => { if (!titleParam) return; const fetchBooks = async () => { try { - // 백엔드 API 호출 (GET /books/search?title=검색어) - const data = await fetchSearchBooks(0, 10, "PUBLISHED_DATE", titleParam); - // 백엔드 응답이 페이지네이션 형태(content 필드가 있을 경우) + const data = await fetchSearchBooks(currentPage, pageSize, sortType, titleParam); setBooks(data.content || data); + setTotalPages(data.totalPages || 1); } catch (error) { console.error("도서 검색 중 오류 발생:", error); } finally { @@ -37,19 +39,31 @@ export default function SearchPage() { }; fetchBooks(); - }, [titleParam]); + }, [titleParam, currentPage, sortType]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + setLoading(true); + }; + + const handleSortChange = (newSort: string) => { + setSortType(newSort); + setCurrentPage(0); + // URL 쿼리 파라미터 업데이트 (정렬 상태 유지) + const params = new URLSearchParams(window.location.search); + params.set("sort", newSort); + router.push(`/search?${params.toString()}`); + }; - if (loading) return

검색 결과 로딩 중...

; - if (!books.length) return

검색 결과가 없습니다.

; + if (loading) return

검색 결과 로딩 중...

; + if (!books.length) return

검색 결과가 없습니다.

; return (

검색 결과: "{titleParam}"

-
- {books.map((book) => ( - - ))} -
+ + +
); } diff --git a/frontend/types/book.ts b/frontend/types/book.ts index 6fd56f7..1fdba8f 100644 --- a/frontend/types/book.ts +++ b/frontend/types/book.ts @@ -10,6 +10,7 @@ export interface Book { pubDate: string; categoryId: number; rating: number; + averageRating: number; reviewCount: number; publisher: string; description: string; From dc3295b5d5d84a0a1fe38e636b8d5d12710ea130 Mon Sep 17 00:00:00 2001 From: pcyscott Date: Wed, 5 Feb 2025 14:50:35 +0900 Subject: [PATCH 12/89] login1-front --- frontend/app/auth/callback/kakao/page.tsx | 32 +++++ frontend/app/components/KakaoLoginButton.tsx | 20 +++ frontend/app/components/NavBar.tsx | 124 +++++++++++-------- frontend/app/hooks/useAuth.ts | 43 +++++++ frontend/app/hooks/useProtectedPage.ts | 14 +++ frontend/package-lock.json | 2 +- frontend/package.json | 14 +-- 7 files changed, 183 insertions(+), 66 deletions(-) create mode 100644 frontend/app/auth/callback/kakao/page.tsx create mode 100644 frontend/app/components/KakaoLoginButton.tsx create mode 100644 frontend/app/hooks/useAuth.ts create mode 100644 frontend/app/hooks/useProtectedPage.ts diff --git a/frontend/app/auth/callback/kakao/page.tsx b/frontend/app/auth/callback/kakao/page.tsx new file mode 100644 index 0000000..f741c39 --- /dev/null +++ b/frontend/app/auth/callback/kakao/page.tsx @@ -0,0 +1,32 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function KakaoCallback() { + const router = useRouter(); + + useEffect(() => { + const fetchUserAndRedirect = async () => { + try { + const response = await fetch("http://localhost:8080/api/auth/me", { + credentials: "include", + }); + + if (!response.ok) throw new Error("Failed to fetch user data"); + + router.push("/my/profile"); // 프로필 페이지로 이동 + } catch (error) { + console.error("Error fetching user data:", error); + router.push("/"); // 로그인 실패 시 홈으로 이동 + } + }; + + fetchUserAndRedirect(); + }, [router]); + + return ( +
+

카카오 로그인 처리중...

+
+ ); +} diff --git a/frontend/app/components/KakaoLoginButton.tsx b/frontend/app/components/KakaoLoginButton.tsx new file mode 100644 index 0000000..2a1c7b2 --- /dev/null +++ b/frontend/app/components/KakaoLoginButton.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export default function KakaoLoginButton() { + const router = useRouter(); + + const handleLogin = () => { + router.push("http://localhost:8080/oauth2/authorization/kakao"); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/app/components/NavBar.tsx b/frontend/app/components/NavBar.tsx index c98c2d6..d7bcc97 100644 --- a/frontend/app/components/NavBar.tsx +++ b/frontend/app/components/NavBar.tsx @@ -1,61 +1,81 @@ -"use client"; +'use client'; import React, { useState, KeyboardEvent } from 'react'; import { useRouter } from 'next/navigation'; +import { useAuth } from '../hooks/useAuth'; +import KakaoLoginButton from './KakaoLoginButton'; export default function NavBar() { - const router = useRouter(); - const [searchText, setSearchText] = useState(''); + const { user } = useAuth(); // useUser 훅 사용 + const router = useRouter(); + const [searchText, setSearchText] = useState(''); - const handleSearch = () => { - // 검색 버튼 클릭 시 /search?title=검색어 로 이동 - router.push(`/search?title=${encodeURIComponent(searchText)}`); - }; + const handleSearch = () => { + // 검색 버튼 클릭 시 /search?title=검색어 로 이동 + router.push(`/search?title=${encodeURIComponent(searchText)}`); + }; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter') { - // 엔터 시 /search?title=검색어 로 이동 - router.push(`/search?title=${encodeURIComponent(searchText)}`); - } - }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + // 엔터 시 /search?title=검색어 로 이동 + router.push(`/search?title=${encodeURIComponent(searchText)}`); + } + }; - return ( -
-
-
- {/* THE BOOK 클릭 시 메인 페이지로 이동 */} -
router.push('/')} - > - THE BOOK -
+ const handleLogout: () => Promise = async () => { + await fetch('http://localhost:8080/api/auth/logout', { + method: 'POST', + credentials: 'include', + }); + window.location.href = '/'; // 새로고침으로 세션 초기화 + }; -
-
- setSearchText(e.target.value)} - onKeyDown={handleKeyDown} - className="w-full px-4 py-2 border border-black rounded-full focus:outline-none focus:ring-2 focus:ring-black" - /> - -
-
- - -
-
-
- ); + return ( +
+
+
+ {/* THE BOOK 클릭 시 메인 페이지로 이동 */} +
router.push('/')} + > + THE BOOK +
+
+ setSearchText(e.target.value)} + onKeyDown={handleKeyDown} + className="border border-gray-300 rounded px-2 py-1" + /> + +
+ +
+
+
+ ); } diff --git a/frontend/app/hooks/useAuth.ts b/frontend/app/hooks/useAuth.ts new file mode 100644 index 0000000..3d87bae --- /dev/null +++ b/frontend/app/hooks/useAuth.ts @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react'; + +interface User { + name: string; + phoneNumber: string; + memberType: 'USER' | 'ADMIN'; // Enum (사용자 역할) + oauthId: string; + email: string; + deliveryInformations: DeliveryInformation[]; +} + +interface DeliveryInformation { + id: number; + address: string; + isDefaultAddress: boolean; +} + +export function useAuth() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUser = async () => { + try { + const res = await fetch('http://localhost:8080/api/auth/me', { + credentials: 'include', + }); + if (res.ok) { + const data = await res.json(); + setUser(data); + } + } catch (error) { + console.error('로그인 정보 가져오기 실패:', error); + } finally { + setLoading(false); + } + }; + + fetchUser(); + }, []); + + return { user, loading }; +} diff --git a/frontend/app/hooks/useProtectedPage.ts b/frontend/app/hooks/useProtectedPage.ts new file mode 100644 index 0000000..1609e99 --- /dev/null +++ b/frontend/app/hooks/useProtectedPage.ts @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from './useAuth'; + +export function useProtectedPage() { + const { user, loading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!loading && !user) { + router.push('/'); + } + }, [loading, user, router]); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ee0ea95..e1335c6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@js-joda/core": "^5.6.4", "axios": "^1.7.9", - "next": "15.1.6", + "next": "^15.1.6", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-scrollbar-hide": "^2.0.0" diff --git a/frontend/package.json b/frontend/package.json index f797cfd..0e26c15 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,17 +9,9 @@ "lint": "next lint" }, "dependencies": { -<<<<<<< HEAD - "next": "15.1.6", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", -======= "@js-joda/core": "^5.6.4", "axios": "^1.7.9", - "next": "15.1.6", + "next": "^15.1.6", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwind-scrollbar-hide": "^2.0.0" @@ -27,17 +19,13 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@openapitools/openapi-generator-cli": "^2.16.3", ->>>>>>> dev "@types/node": "^20", "@types/react": "^19.0.8", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.1.6", "postcss": "^8", -<<<<<<< HEAD -======= "prettier": "^3.4.2", ->>>>>>> dev "tailwindcss": "^3.4.1", "typescript": "^5" } From 9e392b31ba26431f3c383bf42cf398b82cf821ce Mon Sep 17 00:00:00 2001 From: 1m1nkim Date: Wed, 5 Feb 2025 16:13:40 +0900 Subject: [PATCH 13/89] =?UTF-8?q?Feat:=20Bookinfo=20=EB=B3=84=EC=A0=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20CartTest=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Book/Book/BookControllerTest.java | 4 ++ .../Book/Cart/CartControllerTest.java | 66 +++++++++++++++++++ frontend/app/components/book/BookInfo.tsx | 6 +- 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/com/ll/nbe342team8/Book/Book/BookControllerTest.java create mode 100644 backend/src/test/java/com/ll/nbe342team8/Book/Cart/CartControllerTest.java diff --git a/backend/src/test/java/com/ll/nbe342team8/Book/Book/BookControllerTest.java b/backend/src/test/java/com/ll/nbe342team8/Book/Book/BookControllerTest.java new file mode 100644 index 0000000..5ed54df --- /dev/null +++ b/backend/src/test/java/com/ll/nbe342team8/Book/Book/BookControllerTest.java @@ -0,0 +1,4 @@ +package com.ll.nbe342team8.Book.Book; + +public class BookControllerTest { +} diff --git a/backend/src/test/java/com/ll/nbe342team8/Book/Cart/CartControllerTest.java b/backend/src/test/java/com/ll/nbe342team8/Book/Cart/CartControllerTest.java new file mode 100644 index 0000000..a7a8670 --- /dev/null +++ b/backend/src/test/java/com/ll/nbe342team8/Book/Cart/CartControllerTest.java @@ -0,0 +1,66 @@ +package com.ll.nbe342team8.Book.Cart; + +import com.ll.nbe342team8.domain.cart.dto.CartItemRequestDto; +import com.ll.nbe342team8.domain.cart.dto.CartRequestDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class CartControllerTest { + + @Autowired + private TestRestTemplate restTemplate; + // 데이터가 있을 경우에 테스트 시도 + @Test + @DisplayName("장바구니 조회 테스트") + public void testGetCart() { + // URL: GET /cart/1 + // URL: GET /cart/member-id + ResponseEntity response = restTemplate.getForEntity("/cart/1", String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("장바구니 추가 테스트") + public void testAddCart() { + // URL: POST /cart/1/1?quantity=2 + // URL: POST /cart/book-id/member-id?quantity=? + ResponseEntity response = restTemplate.postForEntity("/cart/1/1?quantity=2", null, Void.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("장바구니 수정 테스트") + public void testUpdateCartItem() { + // URL: PUT /cart/1/1?quantity=3 + // URL: PUT /cart/book-id/member-id?quantity=? + HttpHeaders headers = new HttpHeaders(); + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange("/cart/1/1?quantity=3", HttpMethod.PUT, entity, Void.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("장바구니 삭제 테스트") + public void testDeleteCartItem() throws Exception { + // URL: DELETE /cart/1 JSON body + // URL: DELETE /cart/member-id JSON body + CartItemRequestDto itemRequest = new CartItemRequestDto(1L, 1); + CartRequestDto requestDto = new CartRequestDto(Arrays.asList(itemRequest)); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(requestDto, headers); + ResponseEntity response = restTemplate.exchange("/cart/1", HttpMethod.DELETE, entity, Void.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/frontend/app/components/book/BookInfo.tsx b/frontend/app/components/book/BookInfo.tsx index 4b6e770..9cf2772 100644 --- a/frontend/app/components/book/BookInfo.tsx +++ b/frontend/app/components/book/BookInfo.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import Image from 'next/image'; import type { Book } from '@/types/book'; import { addToCart } from '@/utils/api'; +import StarRating from "@/app/search/components/StarRating"; interface BookInfoProps { book: Book; @@ -62,10 +63,13 @@ export const BookInfo: React.FC = ({ book }) => {

평점: {averageRating} ({book.reviewCount}개 리뷰)

+

+ +

-