store add

This commit is contained in:
youbeen 2025-06-16 11:11:57 +09:00
parent bc51e15662
commit 861f181052
17 changed files with 1143 additions and 32 deletions

View File

@ -1,6 +1,8 @@
// common/src/main/java/com/ktds/hi/common/exception/BusinessException.java
package com.ktds.hi.common.exception;
import com.ktds.hi.common.dto.ResponseCode;
/**
* 비즈니스 로직 예외의 기본 클래스
* 모든 커스텀 예외의 부모 클래스
@ -10,6 +12,22 @@ public class BusinessException extends RuntimeException {
private String errorCode;
private Object[] args;
/**
* ResponseCode와 메시지로 예외 생성
*/
public BusinessException(ResponseCode responseCode, String message) {
super(message);
this.errorCode = responseCode.getCode();
}
/**
* ResponseCode로 예외 생성 (기본 메시지 사용)
*/
public BusinessException(ResponseCode responseCode) {
super(responseCode.getMessage());
this.errorCode = responseCode.getCode();
}
/**
* 메시지만으로 예외 생성
*/

View File

@ -3,6 +3,9 @@ package com.ktds.hi.common.security;
import jakarta.servlet.http.HttpServletRequest;
import com.ktds.hi.common.exception.BusinessException;
import com.ktds.hi.common.constants.SecurityConstants;
import jakarta.servlet.http.HttpServletRequest;
import com.ktds.hi.common.exception.BusinessException;
import com.ktds.hi.common.dto.ResponseCode;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
@ -134,6 +137,52 @@ public class JwtTokenProvider {
}
}
/**
* HttpServletRequest에서 점주 ID 추출
*
* @param request HTTP 요청 객체
* @return 점주 ID
*/
public Long extractOwnerIdFromRequest(HttpServletRequest request) {
try {
String token = getJwtFromRequest(request);
if (token == null) {
throw new BusinessException(ResponseCode.UNAUTHORIZED, "토큰이 필요합니다.");
}
if (!validateToken(token)) {
throw new BusinessException(ResponseCode.INVALID_TOKEN, "유효하지 않은 토큰입니다.");
}
String userId = getUserIdFromToken(token);
if (userId == null || userId.trim().isEmpty()) {
throw new BusinessException(ResponseCode.INVALID_TOKEN, "토큰에서 사용자 정보를 찾을 수 없습니다.");
}
return Long.parseLong(userId);
} catch (NumberFormatException e) {
throw new BusinessException(ResponseCode.INVALID_TOKEN, "유효하지 않은 사용자 ID 형식입니다.");
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("토큰에서 점주 ID 추출 실패", e);
throw new BusinessException(ResponseCode.UNAUTHORIZED, "인증에 실패했습니다.");
}
}
/**
* 요청에서 JWT 토큰 추출
*/
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
/**
* 액세스 토큰 생성
*/

View File

@ -0,0 +1,247 @@
// store/src/main/java/com/ktds/hi/store/biz/service/MenuService.java
package com.ktds.hi.store.biz.service;
import com.ktds.hi.common.dto.ResponseCode;
import com.ktds.hi.common.exception.BusinessException;
import com.ktds.hi.store.domain.Menu;
import com.ktds.hi.store.domain.Store;
import com.ktds.hi.store.biz.usecase.in.MenuUseCase;
import com.ktds.hi.store.biz.usecase.out.MenuRepositoryPort;
import com.ktds.hi.store.biz.usecase.out.StoreRepositoryPort;
import com.ktds.hi.store.infra.dto.request.MenuCreateRequest;
import com.ktds.hi.store.infra.dto.request.MenuUpdateRequest;
import com.ktds.hi.store.infra.dto.response.MenuCreateResponse;
import com.ktds.hi.store.infra.dto.response.MenuDetailResponse;
import com.ktds.hi.store.infra.dto.response.MenuUpdateResponse;
import com.ktds.hi.store.infra.dto.response.StoreMenuListResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.annotation.Qualifier;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 메뉴 서비스 구현체
* 메뉴 관련 비즈니스 로직을 처리
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Slf4j
@Service
//@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MenuService implements MenuUseCase {
private final MenuRepositoryPort menuRepositoryPort;
private final StoreRepositoryPort storeRepositoryPort;
public MenuService(@Qualifier("menuJpaAdapter") MenuRepositoryPort menuRepositoryPort,
StoreRepositoryPort storeRepositoryPort) {
this.menuRepositoryPort = menuRepositoryPort;
this.storeRepositoryPort = storeRepositoryPort;
}
@Override
public List<StoreMenuListResponse> getStoreMenus(Long storeId) {
log.info("매장 메뉴 목록 조회 시작 - storeId: {}", storeId);
// 매장 존재 여부 확인
validateStoreExists(storeId);
List<Menu> menus = menuRepositoryPort.findMenusByStoreId(storeId);
return menus.stream()
.map(this::mapToStoreMenuListResponse)
.collect(Collectors.toList());
}
@Override
public MenuDetailResponse getMenuDetail(Long menuId) {
log.info("메뉴 상세 조회 시작 - menuId: {}", menuId);
Menu menu = menuRepositoryPort.findMenuById(menuId)
.orElseThrow(() -> new BusinessException(ResponseCode.NOT_FOUND, "메뉴를 찾을 수 없습니다."));
return mapToMenuDetailResponse(menu);
}
@Override
@Transactional
public MenuCreateResponse createMenu(Long ownerId, Long storeId, MenuCreateRequest request) {
log.info("메뉴 등록 시작 - ownerId: {}, storeId: {}, menuName: {}", ownerId, storeId, request.getMenuName());
// 매장 소유권 확인
validateStoreOwnership(ownerId, storeId);
// 메뉴 생성
Menu menu = Menu.builder()
.storeId(storeId)
.menuName(request.getMenuName())
.description(request.getDescription())
.price(request.getPrice())
.category(request.getCategory())
.imageUrl(request.getImageUrl())
.available(request.getIsAvailable() != null ? request.getIsAvailable() : true)
.orderCount(0)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 메뉴 유효성 검증
if (!menu.isValid()) {
throw new BusinessException(ResponseCode.INVALID_INPUT, "메뉴 정보가 올바르지 않습니다.");
}
Menu savedMenu = menuRepositoryPort.saveMenu(menu);
log.info("메뉴 등록 완료 - menuId: {}", savedMenu.getId());
return MenuCreateResponse.builder()
.menuId(savedMenu.getId())
.message("메뉴가 성공적으로 등록되었습니다.")
.build();
}
@Override
@Transactional
public MenuUpdateResponse updateMenu(Long ownerId, Long menuId, MenuUpdateRequest request) {
log.info("메뉴 수정 시작 - ownerId: {}, menuId: {}", ownerId, menuId);
// 메뉴 조회 소유권 확인
Menu existingMenu = menuRepositoryPort.findMenuById(menuId)
.orElseThrow(() -> new BusinessException(ResponseCode.NOT_FOUND, "메뉴를 찾을 수 없습니다."));
validateStoreOwnership(ownerId, existingMenu.getStoreId());
// 메뉴 정보 업데이트
Menu updatedMenu = existingMenu.updateInfo(
request.getMenuName() != null ? request.getMenuName() : existingMenu.getMenuName(),
request.getDescription() != null ? request.getDescription() : existingMenu.getDescription(),
request.getPrice() != null ? request.getPrice() : existingMenu.getPrice()
);
if (request.getCategory() != null) {
updatedMenu = updatedMenu.updateCategory(request.getCategory());
}
if (request.getImageUrl() != null) {
updatedMenu = updatedMenu.updateImage(request.getImageUrl());
}
if (request.getIsAvailable() != null) {
updatedMenu = updatedMenu.setAvailable(request.getIsAvailable());
}
Menu savedMenu = menuRepositoryPort.saveMenu(updatedMenu);
log.info("메뉴 수정 완료 - menuId: {}", savedMenu.getId());
return MenuUpdateResponse.builder()
.menuId(savedMenu.getId())
.message("메뉴가 성공적으로 수정되었습니다.")
.build();
}
@Override
@Transactional
public void deleteMenu(Long ownerId, Long menuId) {
log.info("메뉴 삭제 시작 - ownerId: {}, menuId: {}", ownerId, menuId);
// 메뉴 조회 소유권 확인
Menu existingMenu = menuRepositoryPort.findMenuById(menuId)
.orElseThrow(() -> new BusinessException(ResponseCode.NOT_FOUND, "메뉴를 찾을 수 없습니다."));
validateStoreOwnership(ownerId, existingMenu.getStoreId());
menuRepositoryPort.deleteMenu(menuId);
log.info("메뉴 삭제 완료 - menuId: {}", menuId);
}
@Override
@Transactional
public void updateMenuAvailability(Long ownerId, Long menuId, Boolean isAvailable) {
log.info("메뉴 가용성 변경 시작 - ownerId: {}, menuId: {}, isAvailable: {}", ownerId, menuId, isAvailable);
// 메뉴 조회 소유권 확인
Menu existingMenu = menuRepositoryPort.findMenuById(menuId)
.orElseThrow(() -> new BusinessException(ResponseCode.NOT_FOUND, "메뉴를 찾을 수 없습니다."));
validateStoreOwnership(ownerId, existingMenu.getStoreId());
Menu updatedMenu = existingMenu.setAvailable(isAvailable);
menuRepositoryPort.saveMenu(updatedMenu);
log.info("메뉴 가용성 변경 완료 - menuId: {}, isAvailable: {}", menuId, isAvailable);
}
@Override
public List<StoreMenuListResponse> getMenusByCategory(Long storeId, String category) {
log.info("카테고리별 메뉴 조회 시작 - storeId: {}, category: {}", storeId, category);
validateStoreExists(storeId);
List<Menu> menus = menuRepositoryPort.findMenusByStoreIdAndCategory(storeId, category);
return menus.stream()
.map(this::mapToStoreMenuListResponse)
.collect(Collectors.toList());
}
/**
* 매장 존재 여부 확인
*/
private void validateStoreExists(Long storeId) {
if (!storeRepositoryPort.findStoreById(storeId).isPresent()) {
throw new BusinessException(ResponseCode.STORE_NOT_FOUND, "매장을 찾을 수 없습니다.");
}
}
/**
* 매장 소유권 확인
*/
private void validateStoreOwnership(Long ownerId, Long storeId) {
Store store = storeRepositoryPort.findStoreByIdAndOwnerId(storeId, ownerId)
.orElseThrow(() -> new BusinessException(ResponseCode.ACCESS_DENIED, "해당 매장에 대한 권한이 없습니다."));
}
/**
* Menu를 StoreMenuListResponse로 변환
*/
private StoreMenuListResponse mapToStoreMenuListResponse(Menu menu) {
return StoreMenuListResponse.builder()
.menuId(menu.getId())
.menuName(menu.getMenuName())
.description(menu.getDescription())
.price(menu.getPrice())
.category(menu.getCategory())
.imageUrl(menu.getImageUrl())
.isAvailable(menu.getAvailable())
.orderCount(menu.getOrderCount())
.build();
}
/**
* Menu를 MenuDetailResponse로 변환
*/
private MenuDetailResponse mapToMenuDetailResponse(Menu menu) {
return MenuDetailResponse.builder()
.menuId(menu.getId())
.storeId(menu.getStoreId())
.menuName(menu.getMenuName())
.description(menu.getDescription())
.price(menu.getPrice())
.category(menu.getCategory())
.imageUrl(menu.getImageUrl())
.isAvailable(menu.getAvailable())
.orderCount(menu.getOrderCount())
.createdAt(menu.getCreatedAt())
.updatedAt(menu.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,75 @@
package com.ktds.hi.store.biz.usecase.in;
import com.ktds.hi.store.infra.dto.response.StoreMenuListResponse;
import java.util.List;
import com.ktds.hi.store.infra.dto.request.MenuCreateRequest;
import com.ktds.hi.store.infra.dto.request.MenuUpdateRequest;
import com.ktds.hi.store.infra.dto.response.MenuCreateResponse;
import com.ktds.hi.store.infra.dto.response.MenuDetailResponse;
import com.ktds.hi.store.infra.dto.response.MenuUpdateResponse;
public interface MenuUseCase {
/**
* 매장 메뉴 목록 조회
*
* @param storeId 매장 ID
* @return 메뉴 목록 응답
*/
List<StoreMenuListResponse> getStoreMenus(Long storeId);
/**
* 메뉴 상세 조회
*
* @param menuId 메뉴 ID
* @return 메뉴 상세 정보
*/
MenuDetailResponse getMenuDetail(Long menuId);
/**
* 메뉴 등록
*
* @param ownerId 점주 ID
* @param storeId 매장 ID
* @param request 메뉴 등록 요청
* @return 메뉴 등록 응답
*/
MenuCreateResponse createMenu(Long ownerId, Long storeId, MenuCreateRequest request);
/**
* 메뉴 수정
*
* @param ownerId 점주 ID
* @param menuId 메뉴 ID
* @param request 메뉴 수정 요청
* @return 메뉴 수정 응답
*/
MenuUpdateResponse updateMenu(Long ownerId, Long menuId, MenuUpdateRequest request);
/**
* 메뉴 삭제
*
* @param ownerId 점주 ID
* @param menuId 메뉴 ID
*/
void deleteMenu(Long ownerId, Long menuId);
/**
* 메뉴 가용성 변경
*
* @param ownerId 점주 ID
* @param menuId 메뉴 ID
* @param isAvailable 가용성 여부
*/
void updateMenuAvailability(Long ownerId, Long menuId, Boolean isAvailable);
/**
* 메뉴 카테고리별 조회
*
* @param storeId 매장 ID
* @param category 카테고리
* @return 카테고리별 메뉴 목록
*/
List<StoreMenuListResponse> getMenusByCategory(Long storeId, String category);
}

View File

@ -21,6 +21,8 @@ public class Menu {
private Boolean available;
private LocalDateTime createdAt; // 추가
private LocalDateTime updatedAt; // 추가
private Integer orderCount;
/**
* 메뉴 정보 업데이트
@ -35,16 +37,93 @@ public class Menu {
}
/**
* 메뉴 판매 상태 변경
* 메뉴 카테고리 업데이트
*/
public void setAvailable(Boolean available) {
this.available = available;
public Menu updateCategory(String category) {
return Menu.builder()
.id(this.id)
.storeId(this.storeId)
.menuName(this.menuName)
.description(this.description)
.price(this.price)
.category(category)
.imageUrl(this.imageUrl)
.available(this.available)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 메뉴 이미지 업데이트
*/
public Menu updateImage(String imageUrl) {
return Menu.builder()
.id(this.id)
.storeId(this.storeId)
.menuName(this.menuName)
.description(this.description)
.price(this.price)
.category(this.category)
.imageUrl(imageUrl)
.available(this.available)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 메뉴 정보 업데이트 (불변 객체 반환)
*/
public Menu updateInfo(String menuName, String description, Integer price) {
return Menu.builder()
.id(this.id)
.storeId(this.storeId)
.menuName(menuName)
.description(description)
.price(price)
.category(this.category)
.imageUrl(this.imageUrl)
.available(this.available)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 메뉴 판매 상태 변경 (불변 객체 반환)
*/
public Menu setAvailable(Boolean available) {
return Menu.builder()
.id(this.id)
.storeId(this.storeId)
.menuName(this.menuName)
.description(this.description)
.price(this.price)
.category(this.category)
.imageUrl(this.imageUrl)
.available(available)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(LocalDateTime.now())
.build();
}
/**
* 메뉴 이용 가능 여부 확인
*/
public boolean isAvailable() {
return this.available != null && this.available;
}
/**
* 메뉴가 유효한지 확인
*/
public boolean isValid() {
return this.menuName != null && !this.menuName.trim().isEmpty() &&
this.price != null && this.price > 0 &&
this.storeId != null;
}
}

View File

@ -0,0 +1,139 @@
package com.ktds.hi.store.infra.controller;
import com.ktds.hi.common.dto.ApiResponse;
import com.ktds.hi.common.security.JwtTokenProvider;
import com.ktds.hi.store.biz.usecase.in.MenuUseCase;
import com.ktds.hi.store.infra.dto.request.MenuCreateRequest;
import com.ktds.hi.store.infra.dto.request.MenuUpdateRequest;
import com.ktds.hi.store.infra.dto.request.MenuAvailabilityRequest;
import com.ktds.hi.store.infra.dto.response.MenuCreateResponse;
import com.ktds.hi.store.infra.dto.response.MenuDetailResponse;
import com.ktds.hi.store.infra.dto.response.MenuUpdateResponse;
import com.ktds.hi.store.infra.dto.response.StoreMenuListResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 메뉴 관리 컨트롤러
* 메뉴 관련 REST API 엔드포인트를 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Slf4j
@RestController
@RequestMapping("/api/menus")
@RequiredArgsConstructor
@Tag(name = "메뉴 관리", description = "메뉴 등록, 조회, 수정, 삭제 API")
public class MenuController {
private final MenuUseCase menuUseCase;
private final JwtTokenProvider jwtTokenProvider;
@GetMapping("/stores/{storeId}")
@Operation(summary = "매장 메뉴 목록 조회", description = "특정 매장의 모든 메뉴를 조회합니다.")
public ResponseEntity<ApiResponse<List<StoreMenuListResponse>>> getStoreMenus(
@Parameter(description = "매장 ID") @PathVariable Long storeId) {
log.info("매장 메뉴 목록 조회 요청 - storeId: {}", storeId);
List<StoreMenuListResponse> response = menuUseCase.getStoreMenus(storeId);
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping("/{menuId}")
@Operation(summary = "메뉴 상세 조회", description = "특정 메뉴의 상세 정보를 조회합니다.")
public ResponseEntity<ApiResponse<MenuDetailResponse>> getMenuDetail(
@Parameter(description = "메뉴 ID") @PathVariable Long menuId) {
log.info("메뉴 상세 조회 요청 - menuId: {}", menuId);
MenuDetailResponse response = menuUseCase.getMenuDetail(menuId);
return ResponseEntity.ok(ApiResponse.success(response));
}
@PostMapping("/stores/{storeId}")
@Operation(summary = "메뉴 등록", description = "새로운 메뉴를 등록합니다.")
public ResponseEntity<ApiResponse<MenuCreateResponse>> createMenu(
@Parameter(description = "매장 ID") @PathVariable Long storeId,
@Parameter(description = "메뉴 등록 정보") @Valid @RequestBody MenuCreateRequest request,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerIdFromRequest(httpRequest);
log.info("메뉴 등록 요청 - ownerId: {}, storeId: {}, menuName: {}", ownerId, storeId, request.getMenuName());
MenuCreateResponse response = menuUseCase.createMenu(ownerId, storeId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
}
@PutMapping("/{menuId}")
@Operation(summary = "메뉴 수정", description = "기존 메뉴 정보를 수정합니다.")
public ResponseEntity<ApiResponse<MenuUpdateResponse>> updateMenu(
@Parameter(description = "메뉴 ID") @PathVariable Long menuId,
@Parameter(description = "메뉴 수정 정보") @Valid @RequestBody MenuUpdateRequest request,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerIdFromRequest(httpRequest);
log.info("메뉴 수정 요청 - ownerId: {}, menuId: {}", ownerId, menuId);
MenuUpdateResponse response = menuUseCase.updateMenu(ownerId, menuId, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@DeleteMapping("/{menuId}")
@Operation(summary = "메뉴 삭제", description = "메뉴를 삭제합니다.")
public ResponseEntity<ApiResponse<Void>> deleteMenu(
@Parameter(description = "메뉴 ID") @PathVariable Long menuId,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerIdFromRequest(httpRequest);
log.info("메뉴 삭제 요청 - ownerId: {}, menuId: {}", ownerId, menuId);
menuUseCase.deleteMenu(ownerId, menuId);
return ResponseEntity.ok(ApiResponse.success(null));
}
@PatchMapping("/{menuId}/availability")
@Operation(summary = "메뉴 가용성 변경", description = "메뉴의 판매 가능 여부를 변경합니다.")
public ResponseEntity<ApiResponse<Void>> updateMenuAvailability(
@Parameter(description = "메뉴 ID") @PathVariable Long menuId,
@Parameter(description = "가용성 변경 정보") @Valid @RequestBody MenuAvailabilityRequest request,
HttpServletRequest httpRequest) {
Long ownerId = jwtTokenProvider.extractOwnerIdFromRequest(httpRequest);
log.info("메뉴 가용성 변경 요청 - ownerId: {}, menuId: {}, isAvailable: {}", ownerId, menuId, request.getIsAvailable());
menuUseCase.updateMenuAvailability(ownerId, menuId, request.getIsAvailable());
return ResponseEntity.ok(ApiResponse.success(null));
}
@GetMapping("/stores/{storeId}/categories/{category}")
@Operation(summary = "카테고리별 메뉴 조회", description = "특정 매장의 카테고리별 메뉴를 조회합니다.")
public ResponseEntity<ApiResponse<List<StoreMenuListResponse>>> getMenusByCategory(
@Parameter(description = "매장 ID") @PathVariable Long storeId,
@Parameter(description = "메뉴 카테고리") @PathVariable String category) {
log.info("카테고리별 메뉴 조회 요청 - storeId: {}, category: {}", storeId, category);
List<StoreMenuListResponse> response = menuUseCase.getMenusByCategory(storeId, category);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -1,10 +1,12 @@
// store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java
package com.ktds.hi.store.infra.controller;
import com.ktds.hi.store.biz.usecase.in.MenuUseCase;
import com.ktds.hi.store.biz.usecase.in.StoreUseCase;
import com.ktds.hi.store.infra.dto.*;
import com.ktds.hi.common.dto.ApiResponse;
import com.ktds.hi.common.security.JwtTokenProvider;
import com.ktds.hi.store.infra.dto.response.StoreMenuListResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@ -37,6 +39,8 @@ public class StoreController {
private final StoreUseCase storeUseCase;
private final JwtTokenProvider jwtTokenProvider;
private final MenuUseCase menuUseCase;
@Operation(summary = "매장 등록", description = "새로운 매장을 등록합니다.")
@PostMapping
@ -117,4 +121,26 @@ public class StoreController {
return ResponseEntity.ok(ApiResponse.success(responses, "매장 검색 완료"));
}
@GetMapping("/{storeId}/menus")
@Operation(summary = "매장 메뉴 목록 조회 (매장 상세에서)", description = "매장 상세 정보 조회 시 메뉴 목록을 함께 제공")
public ResponseEntity<ApiResponse<List<StoreMenuListResponse>>> getStoreMenusFromStore(
@Parameter(description = "매장 ID") @PathVariable Long storeId) {
List<StoreMenuListResponse> response = menuUseCase.getStoreMenus(storeId);
return ResponseEntity.ok(ApiResponse.success(response));
}
@GetMapping("/{storeId}/menus/popular")
@Operation(summary = "매장 인기 메뉴 조회", description = "매장의 인기 메뉴(주문 횟수 기준)를 조회합니다.")
public ResponseEntity<ApiResponse<List<StoreMenuListResponse>>> getPopularMenus(
@Parameter(description = "매장 ID") @PathVariable Long storeId,
@Parameter(description = "조회할 메뉴 개수") @RequestParam(defaultValue = "5") int limit) {
List<StoreMenuListResponse> response = menuUseCase.getStoreMenus(storeId);
// 인기 메뉴 조회 로직 추가 필요
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,23 @@
package com.ktds.hi.store.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 가용성 변경 요청")
public class MenuAvailabilityRequest {
@NotNull(message = "가용성 여부는 필수입니다.")
@Schema(description = "판매 가능 여부", example = "true", required = true)
private Boolean isAvailable;
@Schema(description = "변경 사유", example = "재료 소진으로 인한 일시 판매 중단")
private String reason;
}

View File

@ -0,0 +1,45 @@
package com.ktds.hi.store.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 메뉴 등록 요청 DTO
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 등록 요청")
public class MenuCreateRequest {
@NotBlank(message = "메뉴명은 필수입니다.")
@Schema(description = "메뉴명", example = "치킨버거")
private String menuName;
@Schema(description = "메뉴 설명", example = "바삭한 치킨과 신선한 야채가 들어간 버거")
private String description;
@NotNull(message = "가격은 필수입니다.")
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
@Schema(description = "가격", example = "8500")
private Integer price;
@Schema(description = "카테고리", example = "메인메뉴")
private String category;
@Schema(description = "이미지 URL", example = "/images/chicken-burger.jpg")
private String imageUrl;
@Schema(description = "판매 가능 여부", example = "true")
private Boolean isAvailable;
}

View File

@ -0,0 +1,41 @@
package com.ktds.hi.store.infra.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 메뉴 수정 요청 DTO
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 수정 요청")
public class MenuUpdateRequest {
@Schema(description = "메뉴명", example = "치킨버거")
private String menuName;
@Schema(description = "메뉴 설명", example = "바삭한 치킨과 신선한 야채가 들어간 버거")
private String description;
@Min(value = 0, message = "가격은 0원 이상이어야 합니다.")
@Schema(description = "가격", example = "8500")
private Integer price;
@Schema(description = "카테고리", example = "메인메뉴")
private String category;
@Schema(description = "이미지 URL", example = "/images/chicken-burger.jpg")
private String imageUrl;
@Schema(description = "판매 가능 여부", example = "true")
private Boolean isAvailable;
}

View File

@ -0,0 +1,30 @@
package com.ktds.hi.store.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 메뉴 등록 요청 DTO
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 등록 응답")
public class MenuCreateResponse {
@Schema(description = "생성된 메뉴 ID", example = "1")
private Long menuId;
@Schema(description = "응답 메시지", example = "메뉴가 성공적으로 등록되었습니다.")
private String message;
}

View File

@ -0,0 +1,56 @@
package com.ktds.hi.store.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 메뉴 상세 응답 DTO
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 상세 정보")
public class MenuDetailResponse {
@Schema(description = "메뉴 ID", example = "1")
private Long menuId;
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "메뉴명", example = "치킨버거")
private String menuName;
@Schema(description = "메뉴 설명", example = "바삭한 치킨과 신선한 야채가 들어간 버거")
private String description;
@Schema(description = "가격", example = "8500")
private Integer price;
@Schema(description = "카테고리", example = "메인메뉴")
private String category;
@Schema(description = "이미지 URL", example = "/images/chicken-burger.jpg")
private String imageUrl;
@Schema(description = "판매 가능 여부", example = "true")
private Boolean isAvailable;
@Schema(description = "주문 횟수", example = "25")
private Integer orderCount;
@Schema(description = "생성일시", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "수정일시", example = "2024-01-15T10:30:00")
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,27 @@
package com.ktds.hi.store.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 메뉴 수정 응답 DTO
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "메뉴 수정 응답")
public class MenuUpdateResponse {
@Schema(description = "수정된 메뉴 ID", example = "1")
private Long menuId;
@Schema(description = "응답 메시지", example = "메뉴가 성공적으로 수정되었습니다.")
private String message;
}

View File

@ -0,0 +1,100 @@
// store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreMenuListResponse.java
package com.ktds.hi.store.infra.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 매장 메뉴 목록 응답 DTO
* 매장의 메뉴 목록을 조회할 사용
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 메뉴 목록 응답")
public class StoreMenuListResponse {
@Schema(description = "메뉴 ID", example = "1")
private Long menuId;
@Schema(description = "메뉴명", example = "치킨버거")
private String menuName;
@Schema(description = "메뉴 설명", example = "바삭한 치킨과 신선한 야채가 들어간 버거")
private String description;
@Schema(description = "가격", example = "8500")
private Integer price;
@Schema(description = "카테고리", example = "메인메뉴")
private String category;
@Schema(description = "이미지 URL", example = "/images/chicken-burger.jpg")
private String imageUrl;
@Schema(description = "판매 가능 여부", example = "true")
private Boolean isAvailable;
@Schema(description = "주문 횟수", example = "25")
private Integer orderCount;
/**
* 메뉴가 판매 가능한지 확인
*/
public boolean isMenuAvailable() {
return isAvailable != null && isAvailable;
}
/**
* 가격을 포맷된 문자열로 반환
*/
public String getFormattedPrice() {
if (price == null) {
return "0원";
}
return String.format("%,d원", price);
}
/**
* 인기 메뉴 여부 확인 (주문 횟수 기준)
*/
public boolean isPopularMenu() {
return orderCount != null && orderCount >= 10;
}
/**
* 메뉴 상태 텍스트 반환
*/
public String getStatusText() {
if (isMenuAvailable()) {
return "판매중";
} else {
return "품절";
}
}
/**
* 빌더 패턴을 위한 정적 메서드
*/
public static StoreMenuListResponse of(Long menuId, String menuName, String description,
Integer price, String category, String imageUrl,
Boolean isAvailable, Integer orderCount) {
return StoreMenuListResponse.builder()
.menuId(menuId)
.menuName(menuName)
.description(description)
.price(price)
.category(category)
.imageUrl(imageUrl)
.isAvailable(isAvailable)
.orderCount(orderCount)
.build();
}
}

View File

@ -0,0 +1,86 @@
package com.ktds.hi.store.infra.gateway;
import com.ktds.hi.store.domain.Menu;
import com.ktds.hi.store.biz.usecase.out.MenuRepositoryPort;
import com.ktds.hi.store.infra.gateway.entity.MenuEntity;
import com.ktds.hi.store.infra.gateway.repository.MenuJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 메뉴 JPA 어댑터
* 메뉴 리포지토리 포트의 JPA 구현체
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Component
@RequiredArgsConstructor
public class MenuJpaAdapter implements MenuRepositoryPort {
private final MenuJpaRepository menuJpaRepository;
@Override
public List<Menu> findMenusByStoreId(Long storeId) {
List<MenuEntity> entities = menuJpaRepository.findByStoreId(storeId);
return entities.stream()
.map(MenuEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Menu> findMenuById(Long menuId) {
return menuJpaRepository.findById(menuId)
.map(MenuEntity::toDomain);
}
@Override
public Menu saveMenu(Menu menu) {
MenuEntity entity = MenuEntity.fromDomain(menu);
MenuEntity savedEntity = menuJpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public void deleteMenu(Long menuId) {
menuJpaRepository.deleteById(menuId);
}
@Override
public List<Menu> findAvailableMenusByStoreId(Long storeId) {
List<MenuEntity> entities = menuJpaRepository.findByStoreIdAndIsAvailableTrue(storeId);
return entities.stream()
.map(MenuEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Menu> findMenusByStoreIdAndCategory(Long storeId, String category) {
List<MenuEntity> entities = menuJpaRepository.findByStoreIdAndCategoryAndIsAvailableTrue(storeId, category);
return entities.stream()
.map(MenuEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Menu> saveMenus(List<Menu> menus) {
List<MenuEntity> entities = menus.stream()
.map(MenuEntity::fromDomain)
.collect(Collectors.toList());
List<MenuEntity> savedEntities = menuJpaRepository.saveAll(entities);
return savedEntities.stream()
.map(MenuEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public void deleteMenusByStoreId(Long storeId) {
menuJpaRepository.deleteByStoreId(storeId);
}
}

View File

@ -1,61 +1,106 @@
package com.ktds.hi.store.infra.gateway.entity;
import com.ktds.hi.store.domain.Menu;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 메뉴 엔티티 클래스
* 데이터베이스 menus 테이블과 매핑되는 JPA 엔티티
* 메뉴 엔티티
* 메뉴 정보를 데이터베이스에 저장하기 위한 JPA 엔티티
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Entity
@Table(name = "menus")
@Getter
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class MenuEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "menu_id")
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "menu_name", nullable = false, length = 100)
private String menuName;
@Column(length = 500)
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
@Column(name = "price", nullable = false)
private Integer price;
@Column(length = 50)
@Column(name = "category", length = 50)
private String category;
@Column(name = "image_url", length = 500)
private String imageUrl;
@Column(name = "is_available")
@Column(name = "is_available", nullable = false)
private Boolean isAvailable;
@Column(name = "order_count", nullable = false)
@Builder.Default
private Boolean isAvailable = true;
private Integer orderCount = 0;
@CreatedDate
@Column(name = "created_at", updatable = false)
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
/**
* 엔티티를 도메인 객체로 변환
*/
public Menu toDomain() {
return Menu.builder()
.id(this.id)
.storeId(this.storeId)
.menuName(this.menuName)
.description(this.description)
.price(this.price)
.category(this.category)
.imageUrl(this.imageUrl)
.available(this.isAvailable)
.orderCount(this.orderCount)
.createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build();
}
/**
* 도메인 객체를 엔티티로 변환
*/
public static MenuEntity fromDomain(Menu menu) {
return MenuEntity.builder()
.id(menu.getId())
.storeId(menu.getStoreId())
.menuName(menu.getMenuName())
.description(menu.getDescription())
.price(menu.getPrice())
.category(menu.getCategory())
.imageUrl(menu.getImageUrl())
.isAvailable(menu.getAvailable())
.orderCount(menu.getOrderCount())
.createdAt(menu.getCreatedAt())
.updatedAt(menu.getUpdatedAt())
.build();
}
}

View File

@ -2,6 +2,8 @@ package com.ktds.hi.store.infra.gateway.repository;
import com.ktds.hi.store.infra.gateway.entity.MenuEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@ -12,24 +14,47 @@ import java.util.List;
*/
@Repository
public interface MenuJpaRepository extends JpaRepository<MenuEntity, Long> {
/**
* 매장 ID로 이용 가능한 메뉴 목록 조회
*/
List<MenuEntity> findByStoreIdAndIsAvailableTrue(Long storeId);
/**
* 매장 ID로 모든 메뉴 목록 조회
*/
List<MenuEntity> findByStoreId(Long storeId);
/**
* 매장 ID로 메뉴 삭제
*/
void deleteByStoreId(Long storeId);
/**
* 카테고리별 메뉴 조회
* 카테고리별 이용 가능한 메뉴 조회
*/
List<MenuEntity> findByStoreIdAndCategoryAndIsAvailableTrue(Long storeId, String category);
}
/**
* 매장 ID와 카테고리로 모든 메뉴 조회 (가용성 무관)
*/
List<MenuEntity> findByStoreIdAndCategory(Long storeId, String category);
/**
* 매장별 메뉴 개수 조회
*/
@Query("SELECT COUNT(m) FROM MenuEntity m WHERE m.storeId = :storeId")
Long countByStoreId(@Param("storeId") Long storeId);
/**
* 매장별 이용 가능한 메뉴 개수 조회
*/
@Query("SELECT COUNT(m) FROM MenuEntity m WHERE m.storeId = :storeId AND m.isAvailable = true")
Long countByStoreIdAndIsAvailableTrue(@Param("storeId") Long storeId);
/**
* 인기 메뉴 조회 (주문 횟수 기준 상위 N개)
*/
@Query("SELECT m FROM MenuEntity m WHERE m.storeId = :storeId AND m.isAvailable = true ORDER BY m.orderCount DESC")
List<MenuEntity> findPopularMenusByStoreId(@Param("storeId") Long storeId);
}