diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/EventHubConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/EventHubConfig.java new file mode 100644 index 0000000..4f6d862 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/EventHubConfig.java @@ -0,0 +1,24 @@ +//package com.won.smarketing.recommend.config; +// +//import com.azure.messaging.eventhubs.EventHubClientBuilder; +//import com.azure.messaging.eventhubs.EventHubProducerClient; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.context.annotation.Bean; +//import org.springframework.context.annotation.Configuration; +// +//@Configuration +//public class EventHubConfig { +// +// @Value("${spring.cloud.azure.eventhubs.connection-string}") +// private String connectionString; +// +// @Value("${azure.eventhub.marketing-tip-hub}") +// private String marketingTipHub; +// +// @Bean +// public EventHubProducerClient marketingTipProducerClient() { +// return new EventHubClientBuilder() +// .connectionString(connectionString, marketingTipHub) +// .buildProducerClient(); +// } +//} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/event/MarketingTipRequestEvent.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/event/MarketingTipRequestEvent.java new file mode 100644 index 0000000..821d26f --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/event/MarketingTipRequestEvent.java @@ -0,0 +1,26 @@ +package com.won.smarketing.recommend.domain.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MarketingTipRequestEvent { + + private String requestId; // 요청 고유 ID + private Long userId; // 사용자 ID + private Long storeId; // 매장 ID + private String storeName; // 매장명 + private String businessType; // 업종 + private String location; // 위치 + private Integer seatCount; // 좌석 수 + private String menuData; // 메뉴 데이터 (JSON) + private LocalDateTime requestedAt; // 요청 시각 + private Integer retryCount; // 재시도 횟수 +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AsyncMarketingTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AsyncMarketingTipGenerator.java new file mode 100644 index 0000000..1de9137 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AsyncMarketingTipGenerator.java @@ -0,0 +1,21 @@ +//package com.won.smarketing.recommend.domain.service; +// +//import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +// +//public interface AsyncMarketingTipGenerator { +// +// /** +// * 마케팅 팁 생성 요청 (비동기) +// */ +// String requestMarketingTip(Long userId, StoreWithMenuData storeWithMenuData); +// +// /** +// * 마케팅 팁 상태 조회 +// */ +// MarketingTipStatus getMarketingTipStatus(String requestId); +// +// /** +// * 마케팅 팁 결과 조회 +// */ +// String getMarketingTipResult(String requestId); +//} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventConsumer.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventConsumer.java new file mode 100644 index 0000000..dd6c975 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventConsumer.java @@ -0,0 +1,117 @@ +//package com.won.smarketing.recommend.infrastructure.event; +// +//import com.azure.messaging.eventhubs.EventData; +//import com.azure.messaging.eventhubs.models.EventContext; +//import com.fasterxml.jackson.databind.ObjectMapper; +//import com.won.smarketing.recommend.domain.event.MarketingTipRequestEvent; +//import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; +//import com.won.smarketing.recommend.infrastructure.external.PythonMarketingTipGenerator; +//import com.won.smarketing.recommend.infrastructure.persistence.MarketingTipEntity; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.beans.factory.annotation.Value; +//import org.springframework.stereotype.Service; +// +//import java.time.LocalDateTime; +// +///** +// * 마케팅 팁 이벤트 소비자 +// */ +//@Slf4j +//@Service +//@RequiredArgsConstructor +//public class MarketingTipEventConsumer { +// +// private final ObjectMapper objectMapper; +// private final PythonMarketingTipGenerator pythonMarketingTipGenerator; +// private final MarketingTipRepository marketingTipRepository; +// +// @Value("${azure.eventhub.marketing-tip-hub}") +// private String marketingTipHub; +// +// /** +// * Azure Event Hub 이벤트 처리 +// */ +// public void processMarketingTipRequest(EventContext eventContext) { +// try { +// EventData eventData = eventContext.getEventData(); +// String eventBody = eventData.getBodyAsString(); +// +// MarketingTipRequestEvent request = objectMapper.readValue(eventBody, MarketingTipRequestEvent.class); +// +// log.info("마케팅 팁 요청 처리 시작: requestId={}", request.getRequestId()); +// +// // 상태를 PROCESSING으로 업데이트 +// updateProcessingStatus(request.getRequestId(), MarketingTipEntity.ProcessingStatus.PROCESSING); +// +// // AI 마케팅 팁 생성 +// String marketingTip = generateMarketingTip(request); +// +// // 완료 처리 +// completeMarketingTip(request.getRequestId(), marketingTip); +// +// // 체크포인트 설정 +// eventContext.updateCheckpoint(); +// +// log.info("마케팅 팁 요청 처리 완료: requestId={}", request.getRequestId()); +// +// } catch (Exception e) { +// log.error("마케팅 팁 요청 처리 실패", e); +// handleProcessingError(eventContext, e); +// } +// } +// +// private void updateProcessingStatus(String requestId, MarketingTipEntity.ProcessingStatus status) { +// marketingTipRepository.findByRequestId(requestId) +// .ifPresent(entity -> { +// entity.setStatus(status); +// if (status == MarketingTipEntity.ProcessingStatus.PROCESSING) { +// entity.setUpdatedAt(LocalDateTime.now()); +// } +// marketingTipRepository.save(entity); +// }); +// } +// +// private String generateMarketingTip(MarketingTipRequestEvent request) { +// // StoreWithMenuData 객체 생성 로직 +// // Python AI 서비스 호출 +// return pythonMarketingTipGenerator.generateTipFromEvent(request); +// } +// +// private void completeMarketingTip(String requestId, String tipContent) { +// marketingTipRepository.findByRequestId(requestId) +// .ifPresent(entity -> { +// entity.setStatus(MarketingTipEntity.ProcessingStatus.COMPLETED); +// entity.setTipContent(tipContent); +// entity.setCompletedAt(LocalDateTime.now()); +// +// // 처리 시간 계산 +// if (entity.getCreatedAt() != null) { +// long processingTime = java.time.Duration.between( +// entity.getCreatedAt(), LocalDateTime.now()).getSeconds(); +// entity.setProcessingTimeSeconds((int) processingTime); +// } +// +// marketingTipRepository.save(entity); +// }); +// } +// +// private void handleProcessingError(EventContext eventContext, Exception e) { +// try { +// EventData eventData = eventContext.getEventData(); +// String eventBody = eventData.getBodyAsString(); +// MarketingTipRequestEvent request = objectMapper.readValue(eventBody, MarketingTipRequestEvent.class); +// +// // 실패 상태로 업데이트 +// marketingTipRepository.findByRequestId(request.getRequestId()) +// .ifPresent(entity -> { +// entity.setStatus(MarketingTipEntity.ProcessingStatus.FAILED); +// entity.setErrorMessage(e.getMessage()); +// marketingTipRepository.save(entity); +// }); +// +// } catch (Exception ex) { +// log.error("오류 처리 중 추가 오류 발생", ex); +// } +// } +//} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventPublisher.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventPublisher.java new file mode 100644 index 0000000..092b4db --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventPublisher.java @@ -0,0 +1,43 @@ +//import com.azure.messaging.eventhubs.EventData; +//import com.azure.messaging.eventhubs.EventHubProducerClient; +//import com.fasterxml.jackson.core.JsonProcessingException; +//import com.fasterxml.jackson.databind.ObjectMapper; +//import com.won.smarketing.recommend.domain.event.MarketingTipRequestEvent; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.stereotype.Service; +// +//import java.util.UUID; +// +///** +// * 마케팅 팁 이벤트 발행자 +// */ +//@Slf4j +//@Service +//@RequiredArgsConstructor +//public class MarketingTipEventPublisher { +// +// private final EventHubProducerClient producerClient; +// private final ObjectMapper objectMapper; +// +// public String publishMarketingTipRequest(MarketingTipRequestEvent event) { +// try { +// String requestId = UUID.randomUUID().toString(); +// event.setRequestId(requestId); +// +// String eventData = objectMapper.writeValueAsString(event); +// +// producerClient.send(EventData.create(eventData)); +// +// log.info("마케팅 팁 요청 이벤트 발행 완료: requestId={}", requestId); +// return requestId; +// +// } catch (JsonProcessingException e) { +// log.error("마케팅 팁 이벤트 직렬화 실패", e); +// throw new RuntimeException("이벤트 발행 실패", e); +// } catch (Exception e) { +// log.error("마케팅 팁 이벤트 발행 실패", e); +// throw new RuntimeException("이벤트 발행 실패", e); +// } +// } +//} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonMarketingTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonMarketingTipGenerator.java new file mode 100644 index 0000000..5adea31 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonMarketingTipGenerator.java @@ -0,0 +1,143 @@ +package com.won.smarketing.recommend.infrastructure.external; + +import com.won.smarketing.recommend.domain.model.MenuData; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음 +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Python AI 팁 생성 구현체 (날씨 정보 제거) + */ +@Slf4j +@Service // 추가된 어노테이션 +@RequiredArgsConstructor +public class PythonMarketingTipGenerator implements AiTipGenerator { + + private final WebClient webClient; + + @Value("${external.python-ai-service.base-url}") + private String pythonAiServiceBaseUrl; + + @Value("${external.python-ai-service.api-key}") + private String pythonAiServiceApiKey; + + @Value("${external.python-ai-service.timeout}") + private int timeout; + + @Override + public String generateTip(StoreWithMenuData storeWithMenuData) { + try { + log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName()); + return callPythonAiService(storeWithMenuData); + + } catch (Exception e) { + log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); + return createFallbackTip(storeWithMenuData); + } + } + + private String callPythonAiService(StoreWithMenuData storeWithMenuData) { + + try { + + StoreData storeData = storeWithMenuData.getStoreData(); + List menuDataList = storeWithMenuData.getMenuDataList(); + + // 메뉴 데이터를 Map 형태로 변환 + List> menuList = menuDataList.stream() + .map(menu -> { + Map menuMap = new HashMap<>(); + menuMap.put("menu_id", menu.getMenuId()); + menuMap.put("menu_name", menu.getMenuName()); + menuMap.put("category", menu.getCategory()); + menuMap.put("price", menu.getPrice()); + menuMap.put("description", menu.getDescription()); + return menuMap; + }) + .collect(Collectors.toList()); + + // Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보) + Map requestData = new HashMap<>(); + requestData.put("store_name", storeData.getStoreName()); + requestData.put("business_type", storeData.getBusinessType()); + requestData.put("location", storeData.getLocation()); + requestData.put("seat_count", storeData.getSeatCount()); + requestData.put("menu_list", menuList); + + log.debug("Python AI 서비스 요청 데이터: {}", requestData); + + PythonAiResponse response = webClient + .post() + .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") + .header("Authorization", "Bearer " + pythonAiServiceApiKey) + .header("Content-Type", "application/json") + .bodyValue(requestData) + .retrieve() + .bodyToMono(PythonAiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { + log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length()); + return response.getTip(); + } + } catch (Exception e) { + log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); + } + + return createFallbackTip(storeWithMenuData); + } + + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreWithMenuData storeWithMenuData) { + String businessType = storeWithMenuData.getStoreData().getBusinessType(); + String storeName = storeWithMenuData.getStoreData().getStoreName(); + String location = storeWithMenuData.getStoreData().getLocation(); + + // 업종별 기본 팁 생성 + if (businessType.contains("카페")) { + return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName); + } else if (businessType.contains("음식점") || businessType.contains("식당")) { + return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName); + } else if (businessType.contains("베이커리") || businessType.contains("빵집")) { + return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName); + } else if (businessType.contains("치킨") || businessType.contains("튀김")) { + return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName); + } + + // 지역별 팁 + if (location.contains("강남") || location.contains("서초")) { + return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName); + } else if (location.contains("홍대") || location.contains("신촌")) { + return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName); + } + + // 기본 팁 + return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName); + } + + @Getter + private static class PythonAiResponse { + private String tip; + private String status; + private String message; + private LocalDateTime generatedTip; + private String businessType; + private String aiModel; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index 88d3902..ee94915 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -33,6 +33,12 @@ external: api-key: ${PYTHON_AI_API_KEY:dummy-key} timeout: ${PYTHON_AI_TIMEOUT:30000} +azure: + eventhub: + namespace: ${AZURE_EVENTHUB_NAMESPACE} + marketing-tip-hub: ${AZURE_EVENTHUB_MARKETING_TIP_HUB:marketing-tip-requests} + consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:ai-recommend-service} + management: endpoints: web: diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index 6c51f31..d0a82d4 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -47,6 +47,11 @@ subprojects { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + implementation 'com.azure:azure-messaging-eventhubs:5.18.0' + implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' + implementation 'com.azure:azure-identity:1.11.4' + } tasks.named('test') { diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index 4df4894..e94097e 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -35,4 +35,9 @@ public class MenuUpdateRequest { @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") private String description; + + @Schema(description = "이미지") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") + private MultipartFile image; + }