Merge remote-tracking branch 'origin/ai-recommend' into marketing-contents

This commit is contained in:
박서은 2025-06-17 10:32:34 +09:00
commit 552c05c2d7
9 changed files with 420 additions and 13 deletions

View File

@ -103,10 +103,14 @@ public class MarketingTipService implements MarketingTipUseCase {
String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData); String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
String tipSummary = generateTipSummary(aiGeneratedTip);
log.info("tipSummary : {}", tipSummary);
// 도메인 객체 생성 저장 // 도메인 객체 생성 저장
MarketingTip marketingTip = MarketingTip.builder() MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeWithMenuData.getStoreData().getStoreId()) .storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip) .tipContent(aiGeneratedTip)
.tipSummary(tipSummary)
.storeWithMenuData(storeWithMenuData) .storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now()) .createdAt(LocalDateTime.now())
.build(); .build();
@ -122,11 +126,10 @@ public class MarketingTipService implements MarketingTipUseCase {
* 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함) * 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함)
*/ */
private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) { private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) {
String tipSummary = generateTipSummary(marketingTip.getTipContent());
return MarketingTipResponse.builder() return MarketingTipResponse.builder()
.tipId(marketingTip.getId().getValue()) .tipId(marketingTip.getId().getValue())
.tipSummary(tipSummary) .tipSummary(marketingTip.getTipSummary())
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함 .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
.storeInfo(MarketingTipResponse.StoreInfo.builder() .storeInfo(MarketingTipResponse.StoreInfo.builder()
.storeName(storeData.getStoreName()) .storeName(storeData.getStoreName())
@ -139,24 +142,187 @@ public class MarketingTipService implements MarketingTipUseCase {
.build(); .build();
} }
/**
* 마케팅 요약 생성 ( 50자 또는 번째 문장)
*/
private String generateTipSummary(String fullContent) { private String generateTipSummary(String fullContent) {
if (fullContent == null || fullContent.trim().isEmpty()) { if (fullContent == null || fullContent.trim().isEmpty()) {
return "마케팅 팁이 생성되었습니다."; return "마케팅 팁이 생성되었습니다.";
} }
// 번째 문장으로 요약 (마침표 기준) try {
String[] sentences = fullContent.split("[.!?]"); // JSON 형식 처리: "```html\n..." 패턴
String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent; String processedContent = preprocessContent(fullContent);
// 50자 제한 // 1순위: HTML 블록 밖의 번째 제목 추출
if (firstSentence.length() > 50) { String titleOutsideHtml = extractTitleOutsideHtml(processedContent);
return firstSentence.substring(0, 47) + "..."; if (titleOutsideHtml != null && titleOutsideHtml.length() > 5) {
return titleOutsideHtml;
}
// 2순위: <b> 태그 안의 번째 내용 추출
String boldContent = extractBoldContent(processedContent);
if (boldContent != null && boldContent.length() > 5) {
return boldContent;
}
// 3순위: HTML 태그 제거 번째 문장
return extractFirstSentence(processedContent);
} catch (Exception e) {
log.error("마케팅 팁 요약 생성 중 오류", e);
return "마케팅 팁이 생성되었습니다.";
}
}
/**
* JSON이나 특수 형식 전처리
*/
private String preprocessContent(String content) {
// 먼저 JSON 이스케이프 문자 정리
if (content.contains("\\n")) {
content = content.replaceAll("\\\\n", "\n");
} }
return firstSentence; // JSON 구조에서 실제 HTML 내용만 추출
if (content.contains("```html")) {
content = content.replaceAll("```html", "")
.replaceAll("```", "")
.replaceAll("\"", "");
}
return content.trim();
}
/**
* HTML 블록 밖의 번째 제목 라인 추출
* ```html 이후 번째 줄의 내용만 추출
*/
private String extractTitleOutsideHtml(String content) {
// 먼저 이스케이프 문자 정리
String processedContent = content.replaceAll("\\\\n", "\n");
// ```html 패턴 찾기 (이스케이프 처리 )
String[] htmlPatterns = {"```html\n", "```html\\n"};
for (String pattern : htmlPatterns) {
int htmlStart = processedContent.indexOf(pattern);
if (htmlStart != -1) {
// 패턴 이후부터 시작
int contentStart = htmlStart + pattern.length();
// 번째 줄바꿈까지 또는 \n\n까지 찾기
String remaining = processedContent.substring(contentStart);
String[] lines = remaining.split("\n");
if (lines.length > 0) {
String firstLine = lines[0].trim();
// 유효한 내용인지 확인
if (firstLine.length() > 5 && !firstLine.contains("🎯") && !firstLine.contains("<")) {
return cleanText(firstLine);
}
}
}
}
// 기존 방식으로 fallback
return extractFromLines(processedContent);
}
/**
* 줄별로 처리하는 기존 방식
*/
private String extractFromLines(String content) {
String[] lines = content.split("\n");
for (String line : lines) {
line = line.trim();
// 줄이나 HTML 태그, 이모지로 시작하는 건너뛰기
if (line.isEmpty() ||
line.contains("<") ||
line.startsWith("🎯") ||
line.startsWith("🔍") ||
line.equals("```html") ||
line.matches("^[\\p{So}\\p{Sk}\\s]+$")) {
continue;
}
// 의미있는 제목 라인 발견
if (line.length() > 5) {
return cleanText(line);
}
}
return null;
}
/**
* <b> 태그 안의 번째 내용 추출
*/
private String extractBoldContent(String htmlContent) {
int startIndex = htmlContent.indexOf("<b>");
if (startIndex == -1) {
return null;
}
int endIndex = htmlContent.indexOf("</b>", startIndex);
if (endIndex == -1) {
return null;
}
String content = htmlContent.substring(startIndex + 3, endIndex).trim();
return cleanText(content);
}
/**
* 텍스트 정리
*/
private String cleanText(String text) {
if (text == null) {
return null;
}
return text.replaceAll("&nbsp;", " ")
.replaceAll("\\s+", " ")
.trim();
}
/**
* HTML 태그 제거 번째 의미있는 문장 추출
*/
private String extractFirstSentence(String htmlContent) {
// HTML 태그 모두 제거
String cleanContent = htmlContent.replaceAll("<[^>]+>", "").trim();
// 줄별로 나누어서 번째 의미있는 찾기
String[] lines = cleanContent.split("\\n");
for (String line : lines) {
line = line.trim();
// 줄이나 이모지만 있는 건너뛰기
if (line.isEmpty() || line.matches("^[\\p{So}\\p{Sk}\\s]+$")) {
continue;
}
// 최소 길이 체크하고 반환
if (line.length() > 5) {
// 50자 제한
if (line.length() > 50) {
return line.substring(0, 50).trim() + "...";
}
return line;
}
}
// 모든 방법이 실패하면 기존 방식 사용
String[] sentences = cleanContent.split("[.!?]");
String firstSentence = sentences.length > 0 ? sentences[0].trim() : cleanContent;
if (firstSentence.length() > 50) {
firstSentence = firstSentence.substring(0, 50).trim() + "...";
}
return firstSentence.isEmpty() ? "마케팅 팁이 생성되었습니다." : firstSentence;
} }
/** /**

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

@ -24,7 +24,7 @@ import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service // 추가된 어노테이션 @Service // 추가된 어노테이션
@RequiredArgsConstructor @RequiredArgsConstructor
public class PythonAiTipGenerator implements AiTipGenerator { public class PythonMarketingTipGenerator implements AiTipGenerator {
private final WebClient webClient; private final WebClient webClient;

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,10 @@ 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') {