From 42095eb39e5eb396b78259c4989a4cccd72c6069 Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 10:15:40 +0900 Subject: [PATCH] feat: marketing tip summary --- .../service/MarketingTipService.java | 190 ++++++++++++++++-- .../recommend/config/EventHubConfig.java | 24 +++ .../event/MarketingTipRequestEvent.java | 26 +++ .../service/AsyncMarketingTipGenerator.java | 21 ++ .../event/MarketingTipEventConsumer.java | 117 +++++++++++ .../event/MarketingTipEventPublisher.java | 43 ++++ ....java => PythonMarketingTipGenerator.java} | 2 +- .../src/main/resources/application.yml | 6 + smarketing-java/build.gradle | 4 + 9 files changed, 420 insertions(+), 13 deletions(-) create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/EventHubConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/event/MarketingTipRequestEvent.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AsyncMarketingTipGenerator.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventConsumer.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/event/MarketingTipEventPublisher.java rename smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/{PythonAiTipGenerator.java => PythonMarketingTipGenerator.java} (98%) diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index 49b2801..e6654cf 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -103,10 +103,14 @@ public class MarketingTipService implements MarketingTipUseCase { String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); + String tipSummary = generateTipSummary(aiGeneratedTip); + log.info("tipSummary : {}", tipSummary); + // 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(storeWithMenuData.getStoreData().getStoreId()) .tipContent(aiGeneratedTip) + .tipSummary(tipSummary) .storeWithMenuData(storeWithMenuData) .createdAt(LocalDateTime.now()) .build(); @@ -122,11 +126,10 @@ public class MarketingTipService implements MarketingTipUseCase { * 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함) */ private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) { - String tipSummary = generateTipSummary(marketingTip.getTipContent()); return MarketingTipResponse.builder() .tipId(marketingTip.getId().getValue()) - .tipSummary(tipSummary) + .tipSummary(marketingTip.getTipSummary()) .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함 .storeInfo(MarketingTipResponse.StoreInfo.builder() .storeName(storeData.getStoreName()) @@ -139,24 +142,187 @@ public class MarketingTipService implements MarketingTipUseCase { .build(); } - /** - * 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장) - */ private String generateTipSummary(String fullContent) { if (fullContent == null || fullContent.trim().isEmpty()) { return "마케팅 팁이 생성되었습니다."; } - // 첫 번째 문장으로 요약 (마침표 기준) - String[] sentences = fullContent.split("[.!?]"); - String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent; + try { + // JSON 형식 처리: "```html\n..." 패턴 + String processedContent = preprocessContent(fullContent); - // 50자 제한 - if (firstSentence.length() > 50) { - return firstSentence.substring(0, 47) + "..."; + // 1순위: HTML 블록 밖의 첫 번째 제목 추출 + String titleOutsideHtml = extractTitleOutsideHtml(processedContent); + if (titleOutsideHtml != null && titleOutsideHtml.length() > 5) { + return titleOutsideHtml; + } + + // 2순위: 태그 안의 첫 번째 내용 추출 + 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; + } + + /** + * 태그 안의 첫 번째 내용 추출 + */ + private String extractBoldContent(String htmlContent) { + int startIndex = htmlContent.indexOf(""); + if (startIndex == -1) { + return null; + } + + int endIndex = htmlContent.indexOf("", 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(" ", " ") + .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; } /** 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/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonMarketingTipGenerator.java similarity index 98% rename from smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonMarketingTipGenerator.java index e091fc5..5adea31 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonMarketingTipGenerator.java @@ -24,7 +24,7 @@ import java.util.stream.Collectors; @Slf4j @Service // 추가된 어노테이션 @RequiredArgsConstructor -public class PythonAiTipGenerator implements AiTipGenerator { +public class PythonMarketingTipGenerator implements AiTipGenerator { private final WebClient webClient; 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..30d5b26 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -47,6 +47,10 @@ 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') {