Merge pull request #9 from won-ktds/main-fix

Main fix
This commit is contained in:
yuhalog 2025-06-17 10:58:11 +09:00 committed by GitHub
commit e5b9d03f26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 390 additions and 0 deletions

View File

@ -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();
// }
//}

View File

@ -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; // 재시도 횟수
}

View File

@ -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);
//}

View File

@ -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);
// }
// }
//}

View File

@ -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);
// }
// }
//}

View File

@ -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<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
// 메뉴 데이터를 Map 형태로 변환
List<Map<String, Object>> menuList = menuDataList.stream()
.map(menu -> {
Map<String, Object> 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<String, Object> 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;
}
}

View File

@ -33,6 +33,12 @@ external:
api-key: ${PYTHON_AI_API_KEY:dummy-key} api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000} 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: management:
endpoints: endpoints:
web: web:

View File

@ -47,6 +47,11 @@ subprojects {
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-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') { tasks.named('test') {

View File

@ -35,4 +35,9 @@ public class MenuUpdateRequest {
@Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛")
private String description; private String description;
@Schema(description = "이미지")
@Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다")
private MultipartFile image;
} }