diff --git a/src/main/java/com/campustable/be/domain/cart/controller/CartController.java b/src/main/java/com/campustable/be/domain/cart/controller/CartController.java new file mode 100644 index 0000000..d73210f --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/controller/CartController.java @@ -0,0 +1,60 @@ +package com.campustable.be.domain.cart.controller; + + +import com.campustable.be.domain.cart.dto.CartRequest; +import com.campustable.be.domain.cart.dto.CartResponse; +import com.campustable.be.domain.cart.service.CartService; +import com.campustable.be.global.aop.LogMonitoringInvocation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/cart") +@RequiredArgsConstructor +@Slf4j +public class CartController implements CartControllerDocs{ + + private final CartService cartService; + + @LogMonitoringInvocation + @Override + @PostMapping("/items") + public ResponseEntity addOrUpdateItems(@RequestBody CartRequest request) { + CartResponse response = cartService.updateCartItem(request.getMenuId(), request.getQuantity()); + + return ResponseEntity.ok(response); + } + + @LogMonitoringInvocation + @Override + @GetMapping("") + public ResponseEntity getCart(){ + + CartResponse response = cartService.getCart(); + + return ResponseEntity.ok(response); + } + + + @LogMonitoringInvocation + @Override + @DeleteMapping("/{cartId}") + public void deleteCart(@PathVariable Long cartId) { + cartService.deleteCart(cartId); + } + + @LogMonitoringInvocation + @Override + @DeleteMapping("/items/{cartItemId}") + public void deleteCartItem(@PathVariable Long cartItemId) { + cartService.deleteCartItem(cartItemId); + } +} diff --git a/src/main/java/com/campustable/be/domain/cart/controller/CartControllerDocs.java b/src/main/java/com/campustable/be/domain/cart/controller/CartControllerDocs.java new file mode 100644 index 0000000..a43e310 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/controller/CartControllerDocs.java @@ -0,0 +1,88 @@ +package com.campustable.be.domain.cart.controller; + +import com.campustable.be.domain.cart.dto.CartRequest; +import com.campustable.be.domain.cart.dto.CartResponse; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +public interface CartControllerDocs { + + @Operation( + summary = "장바구니 메뉴 추가 또는 수량 변경", + description = """ + ### 요청 파라미터 + - `menu_id` (Long, required): 장바구니에 담을 메뉴 ID + - `quantity` (int, required): 담을 수량 + - 1 이상: 해당 수량으로 장바구니에 추가 또는 수정 + - 0: 해당 메뉴를 장바구니에서 삭제 + + ### 응답 데이터 + - `cartId` (Long): 장바구니 ID + - `items` (List): 장바구니에 담긴 메뉴 목록 + - `totalPrice` (int): 장바구니 전체 금액 + + ### 특징 + - 수량을 0으로 전달하면 해당 메뉴는 삭제됩니다. + - **장바구니가 비워지면 장바구니 엔티티가 자동 삭제**됩니다. (이 경우 응답에 `cartId`가 포함되지 않을 수 있습니다.) + + ### 예외 처리 + - `MENU_NOT_FOUND` (404): 메뉴 없음 + - `USER_NOT_FOUND` (404): 유저 없음 + """ + ) + ResponseEntity addOrUpdateItems( + @RequestBody CartRequest request + ); + + @Operation( + summary = "장바구니 조회", + description = """ + ### 특징 + - JWT 인증 정보로 현재 로그인한 사용자의 장바구니를 조회합니다. + - 장바구니가 없는 경우 빈 배열(`[]`)과 `totalPrice: 0`을 반환합니다. + + ### 예외 처리 + - `USER_NOT_FOUND` (404): 유저 없음 + - `ACCESS_DENIED` (403): 인증 실패 + """ + ) + ResponseEntity getCart(); + + @Operation( + summary = "장바구니 전체 삭제", + description = """ + ### 요청 파라미터 + - `cartId` (Long): 삭제할 장바구니 ID + + ### 상세 로직 + - 해당 장바구니 ID가 현재 로그인한 사용자의 것인지 검증합니다. + - 사용자와 장바구니의 **연관관계를 명시적으로 끊은 후 삭제**를 진행합니다. + - 장바구니 삭제 시 하위의 모든 아이템도 함께 삭제됩니다. + + ### 예외 처리 + - `CART_NOT_FOUND` (404): 장바구니 없음 + - `ACCESS_DENIED` (403): **본인의 장바구니가 아닌 경우** + """ + ) + void deleteCart(@PathVariable Long cartId); + + @Operation( + summary = "장바구니 특정 메뉴 삭제", + description = """ + ### 요청 파라미터 + - `cartItemId` (Long): 삭제할 장바구니 아이템 ID + + ### 상세 로직 + 1. 해당 `cartItemId`가 현재 로그인한 사용자의 장바구니에 속해 있는지 검증합니다. + 2. 아이템을 삭제한 후, **장바구니에 남은 아이템이 없는지 확인**합니다. + 3. **남은 아이템이 하나도 없다면 장바구니 엔티티도 함께 삭제** 처리합니다. + + ### 예외 처리 + - `CART_ITEM_NOT_FOUND` (404): 아이템 없음 + - `ACCESS_DENIED` (403): **본인의 장바구니 아이템이 아닌 경우** + """ + ) + void deleteCartItem(@PathVariable Long cartItemId); +} \ No newline at end of file diff --git a/src/main/java/com/campustable/be/domain/cart/dto/CartItemDto.java b/src/main/java/com/campustable/be/domain/cart/dto/CartItemDto.java new file mode 100644 index 0000000..c814d05 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/dto/CartItemDto.java @@ -0,0 +1,24 @@ +package com.campustable.be.domain.cart.dto; + + +import com.campustable.be.domain.cart.entity.CartItem; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CartItemDto { + private String menuName; + private int quantity; + private int price; + private String menuUrl; + private Long cartItemId; + + public CartItemDto(CartItem entity) { + this.menuName = entity.getMenu().getMenuName(); + this.price = entity.getMenu().getPrice(); + this.quantity = entity.getQuantity(); + this.menuUrl = entity.getMenu().getMenuUrl(); + this.cartItemId = entity.getCartItemId(); + } +} diff --git a/src/main/java/com/campustable/be/domain/cart/dto/CartRequest.java b/src/main/java/com/campustable/be/domain/cart/dto/CartRequest.java new file mode 100644 index 0000000..00b9049 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/dto/CartRequest.java @@ -0,0 +1,11 @@ +package com.campustable.be.domain.cart.dto; + + +import lombok.Getter; + +@Getter +public class CartRequest { + + private Long menuId; + private int quantity; +} diff --git a/src/main/java/com/campustable/be/domain/cart/dto/CartResponse.java b/src/main/java/com/campustable/be/domain/cart/dto/CartResponse.java new file mode 100644 index 0000000..ac677b3 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/dto/CartResponse.java @@ -0,0 +1,15 @@ +package com.campustable.be.domain.cart.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class CartResponse { + private List items; + private int totalPrice; + private Long cartId; +} diff --git a/src/main/java/com/campustable/be/domain/cart/entity/Cart.java b/src/main/java/com/campustable/be/domain/cart/entity/Cart.java new file mode 100644 index 0000000..ff53742 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/entity/Cart.java @@ -0,0 +1,43 @@ +package com.campustable.be.domain.cart.entity; + +import com.campustable.be.domain.user.entity.User; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import java.util.ArrayList; +import java.util.List; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Cart { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cart_id") + private Long cartId; + + @OneToOne + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) + private List cartItems = new ArrayList<>(); + + public Cart(User user) { + this.user = user; + } + + +} diff --git a/src/main/java/com/campustable/be/domain/cart/entity/CartItem.java b/src/main/java/com/campustable/be/domain/cart/entity/CartItem.java new file mode 100644 index 0000000..e1bc241 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/entity/CartItem.java @@ -0,0 +1,43 @@ +package com.campustable.be.domain.cart.entity; + +import com.campustable.be.domain.menu.entity.Menu; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Getter +@Setter +@NoArgsConstructor +public class CartItem { + + public CartItem(Cart cart, Menu menu){ + this.cart = cart; + this.menu = menu; + } + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name= "cart_item_id") + private Long cartItemId; + + @ManyToOne(fetch = FetchType.LAZY) + private Menu menu; + + @ManyToOne(fetch = FetchType.LAZY) + private Cart cart; + + @Min(value = 0, message = "수량은 0 이상이어야 합니다") + private int quantity; + + + + +} diff --git a/src/main/java/com/campustable/be/domain/cart/repository/CartItemRepository.java b/src/main/java/com/campustable/be/domain/cart/repository/CartItemRepository.java new file mode 100644 index 0000000..ac2f28c --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/repository/CartItemRepository.java @@ -0,0 +1,19 @@ +package com.campustable.be.domain.cart.repository; + +import com.campustable.be.domain.cart.entity.Cart; +import com.campustable.be.domain.cart.entity.CartItem; +import com.campustable.be.domain.menu.entity.Menu; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CartItemRepository extends JpaRepository { + + Optional findByCartAndMenu(Cart cart, Menu menu); + + + List findByCart(Cart cart); + +} diff --git a/src/main/java/com/campustable/be/domain/cart/repository/CartRepository.java b/src/main/java/com/campustable/be/domain/cart/repository/CartRepository.java new file mode 100644 index 0000000..f2106cd --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/repository/CartRepository.java @@ -0,0 +1,13 @@ +package com.campustable.be.domain.cart.repository; + +import com.campustable.be.domain.cart.entity.Cart; +import com.campustable.be.domain.user.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CartRepository extends JpaRepository { + + Optional findByUser(User user); +} diff --git a/src/main/java/com/campustable/be/domain/cart/service/CartService.java b/src/main/java/com/campustable/be/domain/cart/service/CartService.java new file mode 100644 index 0000000..47a6503 --- /dev/null +++ b/src/main/java/com/campustable/be/domain/cart/service/CartService.java @@ -0,0 +1,169 @@ +package com.campustable.be.domain.cart.service; + +import com.campustable.be.domain.cart.dto.CartItemDto; +import com.campustable.be.domain.cart.dto.CartResponse; +import com.campustable.be.domain.cart.entity.Cart; +import com.campustable.be.domain.cart.entity.CartItem; +import com.campustable.be.domain.cart.repository.CartItemRepository; +import com.campustable.be.domain.cart.repository.CartRepository; +import com.campustable.be.domain.menu.entity.Menu; +import com.campustable.be.domain.menu.repository.MenuRepository; +import com.campustable.be.domain.user.entity.User; +import com.campustable.be.domain.user.repository.UserRepository; +import com.campustable.be.global.common.SecurityUtil; +import com.campustable.be.global.exception.CustomException; +import com.campustable.be.global.exception.ErrorCode; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class CartService { + + private final CartItemRepository cartItemRepository; + private final UserRepository userRepository; + private final MenuRepository menuRepository; + private final CartRepository cartRepository; + + public CartResponse updateCartItem(Long menuId, int quantity) { + + Long userId = SecurityUtil.getCurrentUserId(); // JWT에서 추출한 유저 ID + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new CustomException(ErrorCode.MENU_NOT_FOUND)); + + User user = userRepository.findById(userId). + orElseThrow(() -> { + log.error("addOrIncreaseCartItem userId : {}를 db에서 발견하지못했음",userId); + return new CustomException(ErrorCode.USER_NOT_FOUND); + }); + + Cart cart = cartRepository.findByUser(user) + .orElseGet(() -> { + log.info("사용자의 장바구니가 없어 새로 생성합니다. userId: {}", userId); + + return cartRepository.save(new Cart(user)); + }); + + Optional cartItemOpt = cartItemRepository.findByCartAndMenu(cart, menu); + + if (cartItemOpt.isPresent()) { + if (quantity == 0){ + cartItemRepository.delete(cartItemOpt.get()); + } + else cartItemOpt.get().setQuantity(quantity); + } else { + if (quantity > 0) { + CartItem newItem = new CartItem(cart, menu); + newItem.setQuantity(quantity); + cartItemRepository.save(newItem); + } + } + + cartItemRepository.flush(); + + List cartItems = cartItemRepository.findByCart(cart).stream(). + map(CartItemDto::new). + toList(); + + if (cartItems.isEmpty()) { + cart.getUser().setCart(null); + cartRepository.delete(cart); + return CartResponse.builder() + .items(List.of()) + .totalPrice(0) + .build(); + } + + int totalPrice = cartItems.stream() + .mapToInt(item->item.getPrice() * item.getQuantity()) + .sum(); + + return CartResponse.builder(). + items(cartItems) + .totalPrice(totalPrice) + .cartId(cart.getCartId()) + .build(); + } + + public void deleteCartItem(Long cartItemId) { + + Long userId = SecurityUtil.getCurrentUserId(); // JWT에서 추출한 유저 ID + + CartItem cartItem = cartItemRepository.findById(cartItemId) + .orElseThrow(()-> new CustomException(ErrorCode.CART_ITEM_NOT_FOUND)); + + if (!cartItem.getCart().getUser().getUserId().equals(userId)) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + + Cart cart = cartItem.getCart(); + cartItemRepository.delete(cartItem); + + cartItemRepository.flush(); + + if (cartItemRepository.findByCart(cart).isEmpty()) { + cart.getUser().setCart(null); + cartRepository.delete(cart); + } + + } + + public void deleteCart(Long cartId) { + + Long userId = SecurityUtil.getCurrentUserId(); + + Cart cart = cartRepository.findById(cartId) + .orElseThrow(() -> new CustomException(ErrorCode.CART_NOT_FOUND)); + + if (!cart.getUser().getUserId().equals(userId)) { + throw new CustomException(ErrorCode.ACCESS_DENIED); + } + + cart.getUser().setCart(null); + + cartRepository.delete(cart); + } + + public CartResponse getCart(){ + Long userId = SecurityUtil.getCurrentUserId(); // JWT에서 추출한 유저 ID + + User user = userRepository.findById(userId). + orElseThrow(() -> { + log.error("getCart userId : {}를 db에서 발견하지못했음",userId); + return new CustomException(ErrorCode.USER_NOT_FOUND); + }); + + Optional cart = cartRepository.findByUser(user); + + if (cart.isPresent()) { + List cartItems = cartItemRepository.findByCart(cart.get()).stream(). + map(CartItemDto::new). + toList(); + + int totalPrice = cartItems.stream() + .mapToInt(item->item.getPrice() * item.getQuantity()) + .sum(); + + return CartResponse.builder(). + items(cartItems) + .totalPrice(totalPrice) + .cartId(cart.get().getCartId()) + .build(); + } + else{ + return CartResponse.builder() + .items(List.of()) + .totalPrice(0) + .build(); + } + + + } +} diff --git a/src/main/java/com/campustable/be/domain/user/entity/User.java b/src/main/java/com/campustable/be/domain/user/entity/User.java index be4174e..8843298 100644 --- a/src/main/java/com/campustable/be/domain/user/entity/User.java +++ b/src/main/java/com/campustable/be/domain/user/entity/User.java @@ -1,5 +1,6 @@ package com.campustable.be.domain.user.entity; +import com.campustable.be.domain.cart.entity.Cart; import com.campustable.be.domain.user.dto.UserRequest; import com.campustable.be.global.common.BaseTimeEntity; import jakarta.persistence.*; @@ -23,6 +24,9 @@ public class User extends BaseTimeEntity { @Column(name = "user_id") private Long userId; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Cart cart; + @Column(name = "student_number", unique = true, length = 20) private String studentNumber; diff --git a/src/main/java/com/campustable/be/global/common/SecurityUtil.java b/src/main/java/com/campustable/be/global/common/SecurityUtil.java new file mode 100644 index 0000000..2080110 --- /dev/null +++ b/src/main/java/com/campustable/be/global/common/SecurityUtil.java @@ -0,0 +1,33 @@ +package com.campustable.be.global.common; + +import com.campustable.be.global.exception.CustomException; +import com.campustable.be.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +@Slf4j +public class SecurityUtil { + // 인스턴스화 방지 + private SecurityUtil() {} + + public static Long getCurrentUserId() { + // 1. SecurityContext에서 인증 정보를 꺼냄 + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + // 2. 인증 정보가 없거나 익명 사용자인 경우 처리 + if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { + log.error("SecurityUtil class에서 시큐리티 컨텍스트 홀더에 유저가 존재하지않아 에러발생."); + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + + // 3. 저장된 유저 ID 반환 (String으로 저장되므로 Long으로 변환) + + try { + return Long.parseLong(authentication.getName()); + } catch (NumberFormatException e) { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + } + +} diff --git a/src/main/java/com/campustable/be/global/exception/ErrorCode.java b/src/main/java/com/campustable/be/global/exception/ErrorCode.java index 3efca12..ca8b8af 100644 --- a/src/main/java/com/campustable/be/global/exception/ErrorCode.java +++ b/src/main/java/com/campustable/be/global/exception/ErrorCode.java @@ -63,7 +63,13 @@ public enum ErrorCode { //User USER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다."), - USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 유저입니다."); + USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 유저입니다."), + + //Cart + CART_NOT_FOUND(HttpStatus.NOT_FOUND, "장바구니를 찾을수 없습니다."), + + CART_ITEM_NOT_FOUND(HttpStatus.NOT_FOUND, "장바구니 개별목록을 찾을수 없습니다."); + private final HttpStatus status;