diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java index 55043df..5a151f6 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/service/AnalyticsService.java @@ -399,4 +399,138 @@ public class AnalyticsService implements AnalyticsUseCase { // 실제로는 AI 서비스를 통한 감정 분석 필요 return (int) (reviews.size() * 0.2); // 20% 가정 } + + @Override + @Transactional + public AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request) { + log.info("AI 분석 시작: storeId={}, days={}", storeId, request.getDays()); + + try { + // 1. 기존 generateAIFeedback 메서드를 실제 AI 호출로 수정하여 사용 + AiFeedback aiFeedback = generateRealAIFeedback(storeId, request.getDays()); + + // 2. 실행계획 생성 (요청 시) + List actionPlans = null; + if (Boolean.TRUE.equals(request.getGenerateActionPlan())) { + actionPlans = aiServicePort.generateActionPlan(aiFeedback); + } + + // 3. 응답 생성 + AiAnalysisResponse response = AiAnalysisResponse.builder() + .storeId(storeId) + .feedbackId(aiFeedback.getId()) + .summary(aiFeedback.getSummary()) + .positivePoints(aiFeedback.getPositivePoints()) + .improvementPoints(aiFeedback.getImprovementPoints()) + .recommendations(aiFeedback.getRecommendations()) + .sentimentAnalysis(aiFeedback.getSentimentAnalysis()) + .confidenceScore(aiFeedback.getConfidenceScore()) + .totalReviewsAnalyzed(getTotalReviewsCount(storeId, request.getDays())) + .actionPlans(actionPlans) + .analyzedAt(aiFeedback.getGeneratedAt()) + .build(); + + log.info("AI 분석 완료: storeId={}, feedbackId={}", storeId, aiFeedback.getId()); + return response; + + } catch (Exception e) { + log.error("AI 분석 중 오류 발생: storeId={}", storeId, e); + return createErrorAnalysisResponse(storeId); + } + } + + @Override + public List generateActionPlansFromFeedback(Long feedbackId) { + log.info("실행계획 생성: feedbackId={}", feedbackId); + + try { + // 1. AI 피드백 조회 + var aiFeedback = analyticsPort.findAIFeedbackByStoreId(feedbackId); // 실제로는 feedbackId로 조회하는 메서드 필요 + + if (aiFeedback.isEmpty()) { + throw new RuntimeException("AI 피드백을 찾을 수 없습니다: " + feedbackId); + } + + // 2. 기존 AIServicePort.generateActionPlan 메서드 활용 + List actionPlans = aiServicePort.generateActionPlan(aiFeedback.get()); + + log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size()); + return actionPlans; + + } catch (Exception e) { + log.error("실행계획 생성 중 오류 발생: feedbackId={}", feedbackId, e); + return List.of("서비스 개선을 위한 기본 실행계획을 수립하세요."); + } + } + + /** + * 실제 AI를 호출하는 개선된 피드백 생성 메서드 + * 기존 generateAIFeedback()의 하드코딩 부분을 실제 AI 호출로 수정 + */ + @Transactional + public AiFeedback generateRealAIFeedback(Long storeId, Integer days) { + log.info("실제 AI 피드백 생성: storeId={}, days={}", storeId, days); + + try { + // 1. 리뷰 데이터 수집 + List reviewData = externalReviewPort.getRecentReviews(storeId, days); + + if (reviewData.isEmpty()) { + log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId); + return createDefaultAIFeedback(storeId); + } + + // 2. 실제 AI 서비스 호출 (기존 하드코딩 부분을 수정) + AiFeedback aiFeedback = aiServicePort.generateFeedback(reviewData); + + // 3. 도메인 객체 속성 설정 + AiFeedback completeAiFeedback = AiFeedback.builder() + .storeId(storeId) + .summary(aiFeedback.getSummary()) + .positivePoints(aiFeedback.getPositivePoints()) + .improvementPoints(aiFeedback.getImprovementPoints()) + .recommendations(aiFeedback.getRecommendations()) + .sentimentAnalysis(aiFeedback.getSentimentAnalysis()) + .confidenceScore(aiFeedback.getConfidenceScore()) + .generatedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + // 4. 데이터베이스에 저장 + AiFeedback saved = analyticsPort.saveAIFeedback(completeAiFeedback); + + log.info("실제 AI 피드백 생성 완료: storeId={}, reviewCount={}", storeId, reviewData.size()); + return saved; + + } catch (Exception e) { + log.error("실제 AI 피드백 생성 중 오류 발생: storeId={}", storeId, e); + return createDefaultAIFeedback(storeId); + } + } + + private Integer getTotalReviewsCount(Long storeId, Integer days) { + try { + return externalReviewPort.getRecentReviews(storeId, days).size(); + } catch (Exception e) { + log.warn("리뷰 수 조회 실패: storeId={}", storeId, e); + return 0; + } + } + + private AiAnalysisResponse createErrorAnalysisResponse(Long storeId) { + return AiAnalysisResponse.builder() + .storeId(storeId) + .summary("분석 중 오류가 발생했습니다.") + .positivePoints(List.of("현재 분석이 불가능합니다")) + .improvementPoints(List.of("시스템 안정화 후 재시도 필요")) + .recommendations(List.of("잠시 후 다시 분석을 요청해주세요")) + .sentimentAnalysis("분석 실패") + .confidenceScore(0.0) + .totalReviewsAnalyzed(0) + .actionPlans(List.of("기본 실행계획을 수립하세요")) + .analyzedAt(LocalDateTime.now()) + .build(); + } + } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java index 2786ff5..eb13d03 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/biz/usecase/in/AnalyticsUseCase.java @@ -3,6 +3,7 @@ package com.ktds.hi.analytics.biz.usecase.in; import com.ktds.hi.analytics.infra.dto.*; import java.time.LocalDate; +import java.util.List; /** * 분석 서비스 UseCase 인터페이스 @@ -34,4 +35,15 @@ public interface AnalyticsUseCase { * 리뷰 분석 조회 */ ReviewAnalysisResponse getReviewAnalysis(Long storeId); + + /** + * AI 리뷰 분석 및 실행계획 생성 + */ + AiAnalysisResponse generateAIAnalysis(Long storeId, AiAnalysisRequest request); + + /** + * AI 피드백 기반 실행계획 생성 + */ + List generateActionPlansFromFeedback(Long feedbackId); + } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java index 9c0cd5c..0210373 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/controller/AnalyticsController.java @@ -6,6 +6,7 @@ import com.ktds.hi.common.dto.SuccessResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.*; import jakarta.validation.constraints.*; import java.time.LocalDate; +import java.util.List; /** * 분석 서비스 컨트롤러 클래스 @@ -113,4 +115,44 @@ public class AnalyticsController { return ResponseEntity.ok(SuccessResponse.of(response, "리뷰 분석 조회 성공")); } + + + /** + * AI 리뷰 분석 및 실행계획 생성 + */ + @Operation(summary = "AI 리뷰 분석", description = "매장 리뷰를 AI로 분석하고 실행계획을 생성합니다.") + @PostMapping("/stores/{storeId}/ai-analysis") + public ResponseEntity> generateAIAnalysis( + @Parameter(description = "매장 ID", required = true) + @PathVariable @NotNull Long storeId, + + @Parameter(description = "분석 요청 정보") + @RequestBody(required = false) @Valid AiAnalysisRequest request) { + + log.info("AI 리뷰 분석 요청: storeId={}", storeId); + + if (request == null) { + request = AiAnalysisRequest.builder().build(); + } + + AiAnalysisResponse response = analyticsUseCase.generateAIAnalysis(storeId, request); + + return ResponseEntity.ok(SuccessResponse.of(response, "AI 분석 완료")); + } + + /** + * AI 피드백 기반 실행계획 생성 + */ + @Operation(summary = "실행계획 생성", description = "AI 피드백을 기반으로 실행계획을 생성합니다.") + @PostMapping("/ai-feedback/{feedbackId}/action-plans") + public ResponseEntity>> generateActionPlans( + @Parameter(description = "AI 피드백 ID", required = true) + @PathVariable @NotNull Long feedbackId) { + + log.info("실행계획 생성 요청: feedbackId={}", feedbackId); + + List actionPlans = analyticsUseCase.generateActionPlansFromFeedback(feedbackId); + + return ResponseEntity.ok(SuccessResponse.of(actionPlans, "실행계획 생성 완료")); + } } diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java new file mode 100644 index 0000000..f7ac3cc --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisRequest.java @@ -0,0 +1,31 @@ +package com.ktds.hi.analytics.infra.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +/** + * AI 분석 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 리뷰 분석 요청") +public class AiAnalysisRequest { + + @Schema(description = "분석할 최근 일수", example = "30") + @Min(value = 1, message = "분석 기간은 최소 1일 이상이어야 합니다") + @Max(value = 365, message = "분석 기간은 최대 365일까지 가능합니다") + @Builder.Default + private Integer days = 30; + + @Schema(description = "실행계획 자동 생성 여부", example = "true") + @Builder.Default + private Boolean generateActionPlan = true; +} diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java new file mode 100644 index 0000000..38b30f0 --- /dev/null +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/dto/AiAnalysisResponse.java @@ -0,0 +1,54 @@ +package com.ktds.hi.analytics.infra.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI 분석 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "AI 리뷰 분석 결과") +public class AiAnalysisResponse { + + @Schema(description = "매장 ID") + private Long storeId; + + @Schema(description = "AI 피드백 ID") + private Long feedbackId; + + @Schema(description = "분석 요약") + private String summary; + + @Schema(description = "긍정적 요소") + private List positivePoints; + + @Schema(description = "개선점") + private List improvementPoints; + + @Schema(description = "추천사항") + private List recommendations; + + @Schema(description = "감정 분석 결과") + private String sentimentAnalysis; + + @Schema(description = "신뢰도 점수") + private Double confidenceScore; + + @Schema(description = "분석된 리뷰 수") + private Integer totalReviewsAnalyzed; + + @Schema(description = "생성된 실행계획") + private List actionPlans; + + @Schema(description = "분석 완료 시간") + private LocalDateTime analyzedAt; +} \ No newline at end of file diff --git a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java index f16f0f3..3c1c69b 100644 --- a/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java +++ b/analytics/src/main/java/com/ktds/hi/analytics/infra/gateway/AIServiceAdapter.java @@ -18,6 +18,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -96,6 +97,9 @@ public class AIServiceAdapter implements AIServicePort { throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e); } } + + + @Override public SentimentType analyzeSentiment(String content) { @@ -175,40 +179,70 @@ public class AIServiceAdapter implements AIServicePort { * 긍정적 요소 생성 */ private List generatePositivePoints(List reviewData, List sentiments) { - // 실제로는 자연어 처리를 통해 긍정적 키워드 추출 - return Arrays.asList( - "음식 맛에 대한 긍정적 평가가 많습니다", - "직원 서비스에 대한 만족도가 높습니다", - "가격 대비 만족도가 좋습니다" - ); + List positivePoints = new ArrayList<>(); + + long positiveCount = sentiments.stream().mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0).sum(); + double positiveRate = (double) positiveCount / reviewData.size() * 100; + + if (positiveRate > 70) { + positivePoints.add("고객 만족도가 매우 높습니다"); + positivePoints.add("전반적으로 긍정적인 평가를 받고 있습니다"); + positivePoints.add("재방문 의향이 높은 고객들이 많습니다"); + } else if (positiveRate > 50) { + positivePoints.add("평균 이상의 고객 만족도를 보입니다"); + positivePoints.add("많은 고객들이 만족하고 있습니다"); + } else { + positivePoints.add("일부 고객들이 긍정적으로 평가하고 있습니다"); + positivePoints.add("개선의 여지가 있습니다"); + } + + return positivePoints; } /** * 개선점 생성 */ private List generateImprovementPoints(List reviewData, List sentiments) { - // 실제로는 자연어 처리를 통해 부정적 키워드 추출 - return Arrays.asList( - "배달 시간 단축이 필요합니다", - "음식 포장 상태 개선이 필요합니다", - "메뉴 다양성 확대를 고려해보세요" - ); + List improvementPoints = new ArrayList<>(); + + long negativeCount = sentiments.stream().mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0).sum(); + double negativeRate = (double) negativeCount / reviewData.size() * 100; + + if (negativeRate > 30) { + improvementPoints.add("고객 서비스 품질 개선이 시급합니다"); + improvementPoints.add("부정적 피드백에 대한 체계적 대응이 필요합니다"); + improvementPoints.add("근본적인 서비스 개선 방안을 마련해야 합니다"); + } else if (negativeRate > 15) { + improvementPoints.add("일부 서비스 영역에서 개선이 필요합니다"); + improvementPoints.add("고객 만족도 향상을 위한 노력이 필요합니다"); + } else { + improvementPoints.add("현재 서비스 수준을 유지하며 세부 개선점을 찾아보세요"); + improvementPoints.add("더 높은 고객 만족을 위한 차별화 요소를 개발하세요"); + } + + return improvementPoints; } /** * 추천사항 생성 */ private List generateRecommendations(double positiveRate, double negativeRate) { - List recommendations = Arrays.asList( - "고객 피드백을 정기적으로 모니터링하고 대응하세요", - "리뷰에 적극적으로 댓글을 달아 고객과 소통하세요", - "메뉴 품질 관리 체계를 강화하세요" - ); - - if (negativeRate > 30) { - recommendations.add("긴급히 서비스 품질 개선 계획을 수립하세요"); + List recommendations = new ArrayList<>(); + + if (positiveRate > 70) { + recommendations.add("현재의 우수한 서비스를 유지하면서 브랜드 가치를 높이세요"); + recommendations.add("긍정적 리뷰를 마케팅 자료로 활용하세요"); + recommendations.add("고객 충성도 프로그램을 도입하세요"); + } else if (negativeRate > 30) { + recommendations.add("고객 불만사항에 대한 즉각적인 대응 체계를 구축하세요"); + recommendations.add("직원 교육을 통한 서비스 품질 향상에 집중하세요"); + recommendations.add("고객 피드백 수집 및 분석 프로세스를 강화하세요"); + } else { + recommendations.add("지속적인 품질 관리와 고객 만족도 모니터링을 실시하세요"); + recommendations.add("차별화된 서비스 제공을 통해 경쟁력을 강화하세요"); + recommendations.add("고객과의 소통을 늘려 관계를 강화하세요"); } - + return recommendations; } @@ -216,25 +250,34 @@ public class AIServiceAdapter implements AIServicePort { * 신뢰도 점수 계산 */ private double calculateConfidenceScore(int reviewCount) { - if (reviewCount >= 100) return 0.95; - if (reviewCount >= 50) return 0.85; - if (reviewCount >= 20) return 0.75; - if (reviewCount >= 10) return 0.65; - return 0.5; + if (reviewCount >= 50) { + return 0.9; + } else if (reviewCount >= 20) { + return 0.75; + } else if (reviewCount >= 10) { + return 0.6; + } else if (reviewCount >= 5) { + return 0.4; + } else { + return 0.2; + } } /** * 개선점을 실행 계획으로 변환 */ private String convertToActionPlan(String improvementPoint) { - // 개선점을 구체적인 실행 계획으로 변환하는 로직 - if (improvementPoint.contains("배달 시간")) { - return "배달 시간 단축을 위한 배달 경로 최적화 및 인력 증원 검토"; - } else if (improvementPoint.contains("포장")) { - return "음식 포장 재료 교체 및 포장 방법 개선"; + // 개선점을 구체적인 실행계획으로 변환 + if (improvementPoint.contains("서비스 품질")) { + return "직원 서비스 교육 프로그램 실시 (월 1회, 2시간)"; + } else if (improvementPoint.contains("대기시간")) { + return "주문 처리 시스템 개선 및 대기열 관리 체계 도입"; + } else if (improvementPoint.contains("가격")) { + return "경쟁사 가격 분석 및 합리적 가격 정책 수립"; } else if (improvementPoint.contains("메뉴")) { - return "고객 선호도 조사를 통한 신메뉴 개발 계획 수립"; + return "고객 선호도 조사를 통한 메뉴 다양화 방안 검토"; + } else { + return "고객 피드백 기반 서비스 개선 계획 수립"; } - return improvementPoint + "을 위한 구체적인 실행 방안 수립"; } } diff --git a/analytics/src/main/resources/application.yml b/analytics/src/main/resources/application.yml index d140f55..3b2e861 100644 --- a/analytics/src/main/resources/application.yml +++ b/analytics/src/main/resources/application.yml @@ -52,9 +52,9 @@ ai-api: # 외부 서비스 설정 external: services: - review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8082} - store: ${EXTERNAL_SERVICES_STORE:http://localhost:8081} - member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8080} + review: ${EXTERNAL_SERVICES_REVIEW:http://localhost:8083} + store: ${EXTERNAL_SERVICES_STORE:http://localhost:8082} + member: ${EXTERNAL_SERVICES_MEMBER:http://localhost:8081} #springdoc: # api-docs: @@ -83,6 +83,9 @@ ai: key: ${AI_AZURE_COGNITIVE_KEY:your-cognitive-service-key} openai: api-key: ${AI_OPENAI_API_KEY:your-openai-api-key} + claude: + api-key: ${AI_CLAUDE_KEY:your-claude-key} + endpoint: ${AI_CLAUDE_ENDPOINT:https://api.anthropic.com} # Azure Event Hub 설정 azure: diff --git a/common/src/main/java/com/ktds/hi/common/exception/BusinessException.java b/common/src/main/java/com/ktds/hi/common/exception/BusinessException.java index e7c707d..360884a 100644 --- a/common/src/main/java/com/ktds/hi/common/exception/BusinessException.java +++ b/common/src/main/java/com/ktds/hi/common/exception/BusinessException.java @@ -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(); + } + /** * 메시지만으로 예외 생성 */ diff --git a/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java b/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java index dd3a104..b01fe1d 100644 --- a/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java +++ b/common/src/main/java/com/ktds/hi/common/security/JwtTokenProvider.java @@ -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; + } + /** * 액세스 토큰 생성 */ diff --git a/store/src/main/java/com/ktds/hi/store/biz/service/MenuService.java b/store/src/main/java/com/ktds/hi/store/biz/service/MenuService.java new file mode 100644 index 0000000..68117ce --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/service/MenuService.java @@ -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 getStoreMenus(Long storeId) { + log.info("매장 메뉴 목록 조회 시작 - storeId: {}", storeId); + + // 매장 존재 여부 확인 + validateStoreExists(storeId); + + List 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 getMenusByCategory(Long storeId, String category) { + log.info("카테고리별 메뉴 조회 시작 - storeId: {}, category: {}", storeId, category); + + validateStoreExists(storeId); + + List 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(); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/biz/usecase/in/MenuUseCase.java b/store/src/main/java/com/ktds/hi/store/biz/usecase/in/MenuUseCase.java new file mode 100644 index 0000000..5e375a6 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/biz/usecase/in/MenuUseCase.java @@ -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 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 getMenusByCategory(Long storeId, String category); +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/domain/Menu.java b/store/src/main/java/com/ktds/hi/store/domain/Menu.java index ad92eec..c3cef27 100644 --- a/store/src/main/java/com/ktds/hi/store/domain/Menu.java +++ b/store/src/main/java/com/ktds/hi/store/domain/Menu.java @@ -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; + } } \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/controller/MenuController.java b/store/src/main/java/com/ktds/hi/store/infra/controller/MenuController.java new file mode 100644 index 0000000..f9a4adf --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/controller/MenuController.java @@ -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>> getStoreMenus( + @Parameter(description = "매장 ID") @PathVariable Long storeId) { + + log.info("매장 메뉴 목록 조회 요청 - storeId: {}", storeId); + + List response = menuUseCase.getStoreMenus(storeId); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @GetMapping("/{menuId}") + @Operation(summary = "메뉴 상세 조회", description = "특정 메뉴의 상세 정보를 조회합니다.") + public ResponseEntity> 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> 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> 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> 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> 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>> getMenusByCategory( + @Parameter(description = "매장 ID") @PathVariable Long storeId, + @Parameter(description = "메뉴 카테고리") @PathVariable String category) { + + log.info("카테고리별 메뉴 조회 요청 - storeId: {}, category: {}", storeId, category); + + List response = menuUseCase.getMenusByCategory(storeId, category); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java b/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java index 00cbbf3..35bffa7 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java +++ b/store/src/main/java/com/ktds/hi/store/infra/controller/StoreController.java @@ -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>> getStoreMenusFromStore( + @Parameter(description = "매장 ID") @PathVariable Long storeId) { + + List response = menuUseCase.getStoreMenus(storeId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + + + @GetMapping("/{storeId}/menus/popular") + @Operation(summary = "매장 인기 메뉴 조회", description = "매장의 인기 메뉴(주문 횟수 기준)를 조회합니다.") + public ResponseEntity>> getPopularMenus( + @Parameter(description = "매장 ID") @PathVariable Long storeId, + @Parameter(description = "조회할 메뉴 개수") @RequestParam(defaultValue = "5") int limit) { + + List response = menuUseCase.getStoreMenus(storeId); + // 인기 메뉴 조회 로직 추가 필요 + return ResponseEntity.ok(ApiResponse.success(response)); + } } \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuAvailabilityRequest.java b/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuAvailabilityRequest.java new file mode 100644 index 0000000..8b06413 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuAvailabilityRequest.java @@ -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; +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuCreateRequest.java b/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuCreateRequest.java new file mode 100644 index 0000000..89cf7d3 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuCreateRequest.java @@ -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; +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuUpdateRequest.java b/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuUpdateRequest.java new file mode 100644 index 0000000..a23b982 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/request/MenuUpdateRequest.java @@ -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; +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuCreateResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuCreateResponse.java new file mode 100644 index 0000000..ec0035f --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuCreateResponse.java @@ -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; +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuDetailResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuDetailResponse.java new file mode 100644 index 0000000..4525e7f --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuDetailResponse.java @@ -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; +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuUpdateResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuUpdateResponse.java new file mode 100644 index 0000000..c9fce63 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/MenuUpdateResponse.java @@ -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; +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreMenuListResponse.java b/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreMenuListResponse.java new file mode 100644 index 0000000..5a38cd4 --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/dto/response/StoreMenuListResponse.java @@ -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(); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/MenuJpaAdapter.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/MenuJpaAdapter.java new file mode 100644 index 0000000..a44230d --- /dev/null +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/MenuJpaAdapter.java @@ -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 findMenusByStoreId(Long storeId) { + List entities = menuJpaRepository.findByStoreId(storeId); + return entities.stream() + .map(MenuEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public Optional 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 findAvailableMenusByStoreId(Long storeId) { + List entities = menuJpaRepository.findByStoreIdAndIsAvailableTrue(storeId); + return entities.stream() + .map(MenuEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List findMenusByStoreIdAndCategory(Long storeId, String category) { + List entities = menuJpaRepository.findByStoreIdAndCategoryAndIsAvailableTrue(storeId, category); + return entities.stream() + .map(MenuEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public List saveMenus(List menus) { + List entities = menus.stream() + .map(MenuEntity::fromDomain) + .collect(Collectors.toList()); + + List savedEntities = menuJpaRepository.saveAll(entities); + + return savedEntities.stream() + .map(MenuEntity::toDomain) + .collect(Collectors.toList()); + } + + @Override + public void deleteMenusByStoreId(Long storeId) { + menuJpaRepository.deleteByStoreId(storeId); + } +} diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java index ebf171e..11a26a5 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/entity/MenuEntity.java @@ -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(); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java index 58bdefd..d926bf8 100644 --- a/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java +++ b/store/src/main/java/com/ktds/hi/store/infra/gateway/repository/MenuJpaRepository.java @@ -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 { - + /** * 매장 ID로 이용 가능한 메뉴 목록 조회 */ List findByStoreIdAndIsAvailableTrue(Long storeId); - + /** * 매장 ID로 모든 메뉴 목록 조회 */ List findByStoreId(Long storeId); - + /** * 매장 ID로 메뉴 삭제 */ void deleteByStoreId(Long storeId); - + /** - * 카테고리별 메뉴 조회 + * 카테고리별 이용 가능한 메뉴 조회 */ List findByStoreIdAndCategoryAndIsAvailableTrue(Long storeId, String category); -} + + /** + * 매장 ID와 카테고리로 모든 메뉴 조회 (가용성 무관) + */ + List 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 findPopularMenusByStoreId(@Param("storeId") Long storeId); +} \ No newline at end of file