feat : 서비스 분석 및 실행결과 저장기능 개발.
This commit is contained in:
parent
2373a07772
commit
84ef442775
@ -475,6 +475,8 @@ public class AnalyticsService implements AnalyticsUseCase {
|
||||
// 1. 리뷰 데이터 수집
|
||||
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days);
|
||||
|
||||
log.info("review Data check ===> {}", reviewData);
|
||||
|
||||
if (reviewData.isEmpty()) {
|
||||
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
|
||||
return createDefaultAIFeedback(storeId);
|
||||
|
||||
@ -8,19 +8,30 @@ import com.azure.ai.textanalytics.models.AnalyzeSentimentResult;
|
||||
import com.azure.ai.textanalytics.models.DocumentSentiment;
|
||||
import com.azure.ai.textanalytics.models.TextSentiment;
|
||||
import com.azure.core.credential.AzureKeyCredential;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 서비스 어댑터 클래스
|
||||
@ -29,135 +40,356 @@ import java.util.List;
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AIServiceAdapter implements AIServicePort {
|
||||
|
||||
@Value("${ai.azure.cognitive.endpoint}")
|
||||
private String cognitiveEndpoint;
|
||||
|
||||
@Value("${ai.azure.cognitive.key}")
|
||||
private String cognitiveKey;
|
||||
|
||||
@Value("${ai.openai.api-key}")
|
||||
|
||||
|
||||
@Value("${ai-api.openai.base-url:https://api.openai.com/v1}")
|
||||
private String openaiBaseUrl;
|
||||
|
||||
@Value("${ai-api.openai.api-key}")
|
||||
private String openaiApiKey;
|
||||
|
||||
@Value("${ai-api.openai.model:gpt-4o-mini}")
|
||||
private String openaiModel;
|
||||
|
||||
private TextAnalyticsClient textAnalyticsClient;
|
||||
|
||||
private RestTemplate restTemplate;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
public void initializeClients() {
|
||||
// Azure Cognitive Services 클라이언트 초기화
|
||||
textAnalyticsClient = new TextAnalyticsClientBuilder()
|
||||
.credential(new AzureKeyCredential(cognitiveKey))
|
||||
.endpoint(cognitiveEndpoint)
|
||||
.buildClient();
|
||||
|
||||
log.info("AI 서비스 클라이언트 초기화 완료");
|
||||
// textAnalyticsClient = new TextAnalyticsClientBuilder()
|
||||
// .credential(new AzureKeyCredential(cognitiveKey))
|
||||
// .endpoint(cognitiveEndpoint)
|
||||
// .buildClient();
|
||||
//
|
||||
// log.info("AI 서비스 클라이언트 초기화 완료");
|
||||
|
||||
// OpenAI API 클라이언트 초기화
|
||||
restTemplate = new RestTemplate();
|
||||
objectMapper = new ObjectMapper();
|
||||
|
||||
if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) {
|
||||
log.warn("OpenAI API 키가 설정되지 않았습니다. AI 기능이 제한될 수 있습니다.");
|
||||
} else {
|
||||
log.info("OpenAI API 클라이언트 초기화 완료");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AiFeedback generateFeedback(List<String> reviewData) {
|
||||
log.info("AI 피드백 생성 시작: 리뷰 수={}", reviewData.size());
|
||||
|
||||
|
||||
log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size());
|
||||
|
||||
try {
|
||||
if (reviewData.isEmpty()) {
|
||||
return createEmptyFeedback();
|
||||
}
|
||||
|
||||
// 1. 감정 분석 수행
|
||||
List<SentimentType> sentiments = reviewData.stream()
|
||||
.map(this::analyzeSentiment)
|
||||
.toList();
|
||||
|
||||
// 2. 긍정/부정 비율 계산
|
||||
long positiveCount = sentiments.stream()
|
||||
.mapToLong(s -> s == SentimentType.POSITIVE ? 1 : 0)
|
||||
.sum();
|
||||
|
||||
long negativeCount = sentiments.stream()
|
||||
.mapToLong(s -> s == SentimentType.NEGATIVE ? 1 : 0)
|
||||
.sum();
|
||||
|
||||
double positiveRate = (double) positiveCount / reviewData.size() * 100;
|
||||
double negativeRate = (double) negativeCount / reviewData.size() * 100;
|
||||
|
||||
// 3. 피드백 생성
|
||||
AiFeedback feedback = AiFeedback.builder()
|
||||
.summary(generateSummary(positiveRate, negativeRate, reviewData.size()))
|
||||
.positivePoints(generatePositivePoints(reviewData, sentiments))
|
||||
.improvementPoints(generateImprovementPoints(reviewData, sentiments))
|
||||
.recommendations(generateRecommendations(positiveRate, negativeRate))
|
||||
.sentimentAnalysis(String.format("긍정: %.1f%%, 부정: %.1f%%", positiveRate, negativeRate))
|
||||
.confidenceScore(calculateConfidenceScore(reviewData.size()))
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
log.info("AI 피드백 생성 완료: 긍정률={}%, 부정률={}%", positiveRate, negativeRate);
|
||||
return feedback;
|
||||
|
||||
|
||||
// OpenAI API 호출하여 전체 리뷰 분석
|
||||
String analysisResult = callOpenAIForAnalysis(reviewData);
|
||||
|
||||
// 결과 파싱 및 AiFeedback 객체 생성
|
||||
return parseAnalysisResult(analysisResult, reviewData.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("AI 피드백 생성 중 오류 발생", e);
|
||||
throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e);
|
||||
log.error("OpenAI 피드백 생성 중 오류 발생", e);
|
||||
return createFallbackFeedback(reviewData);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public SentimentType analyzeSentiment(String content) {
|
||||
try {
|
||||
DocumentSentiment documentSentiment = textAnalyticsClient.analyzeSentiment(content);
|
||||
TextSentiment sentiment = documentSentiment.getSentiment();
|
||||
String prompt = String.format(
|
||||
"다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s",
|
||||
content
|
||||
);
|
||||
|
||||
if (sentiment == TextSentiment.POSITIVE) {
|
||||
String result = callOpenAI(prompt);
|
||||
|
||||
if (result.toUpperCase().contains("POSITIVE")) {
|
||||
return SentimentType.POSITIVE;
|
||||
} else if (sentiment == TextSentiment.NEGATIVE) {
|
||||
} else if (result.toUpperCase().contains("NEGATIVE")) {
|
||||
return SentimentType.NEGATIVE;
|
||||
} else if (sentiment == TextSentiment.NEUTRAL) {
|
||||
return SentimentType.NEUTRAL;
|
||||
} else if (sentiment == TextSentiment.MIXED) {
|
||||
return SentimentType.NEUTRAL; // MIXED는 NEUTRAL로 처리
|
||||
} else {
|
||||
return SentimentType.NEUTRAL;
|
||||
}
|
||||
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length())));
|
||||
log.warn("OpenAI 감정 분석 실패, 중립으로 처리: content={}", content.substring(0, Math.min(50, content.length())));
|
||||
return SentimentType.NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> generateActionPlan(AiFeedback feedback) {
|
||||
log.info("실행 계획 생성 시작");
|
||||
|
||||
log.info("OpenAI 실행 계획 생성 시작");
|
||||
|
||||
try {
|
||||
// 개선점을 기반으로 실행 계획 생성
|
||||
List<String> actionPlans = feedback.getImprovementPoints().stream()
|
||||
.map(this::convertToActionPlan)
|
||||
.toList();
|
||||
|
||||
log.info("실행 계획 생성 완료: 계획 수={}", actionPlans.size());
|
||||
return actionPlans;
|
||||
|
||||
String prompt = String.format(
|
||||
"""
|
||||
다음 AI 피드백을 바탕으로 구체적인 실행 계획 3개를 생성해주세요.
|
||||
각 계획은 실행 가능하고 구체적이어야 합니다.
|
||||
|
||||
요약: %s
|
||||
개선점: %s
|
||||
|
||||
실행 계획을 다음 형식으로 작성해주세요:
|
||||
1. [구체적인 실행 계획 1]
|
||||
2. [구체적인 실행 계획 2]
|
||||
3. [구체적인 실행 계획 3]
|
||||
""",
|
||||
feedback.getSummary(),
|
||||
String.join(", ", feedback.getImprovementPoints())
|
||||
);
|
||||
|
||||
String result = callOpenAI(prompt);
|
||||
return parseActionPlans(result);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("실행 계획 생성 중 오류 발생", e);
|
||||
return Arrays.asList("서비스 품질 개선을 위한 직원 교육 실시", "고객 피드백 수집 체계 구축");
|
||||
log.error("OpenAI 실행 계획 생성 중 오류 발생", e);
|
||||
return Arrays.asList(
|
||||
"서비스 품질 개선을 위한 직원 교육 실시",
|
||||
"고객 피드백 수집 체계 구축",
|
||||
"매장 운영 프로세스 개선"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* OpenAI API를 호출하여 전체 리뷰 분석 수행
|
||||
*/
|
||||
private String callOpenAIForAnalysis(List<String> reviewData) {
|
||||
String reviewsText = String.join("\n- ", reviewData);
|
||||
|
||||
String prompt = String.format(
|
||||
"""
|
||||
다음은 한 매장의 고객 리뷰들입니다. 이를 분석하여 다음 JSON 형식으로 답변해주세요:
|
||||
|
||||
{
|
||||
"summary": "전체적인 분석 요약 (2-3문장)",
|
||||
"positivePoints": ["긍정적 요소1", "긍정적 요소2", "긍정적 요소3"],
|
||||
"improvementPoints": ["개선점1", "개선점2", "개선점3"],
|
||||
"recommendations": ["추천사항1", "추천사항2", "추천사항3"],
|
||||
"sentimentAnalysis": "전체적인 감정 분석 결과",
|
||||
"confidenceScore": 0.85
|
||||
}
|
||||
|
||||
리뷰 목록:
|
||||
- %s
|
||||
|
||||
분석 시 다음 사항을 고려해주세요:
|
||||
1. 긍정적 요소는 고객들이 자주 언급하는 좋은 점들
|
||||
2. 개선점은 부정적 피드백이나 불만사항
|
||||
3. 추천사항은 매장 운영에 도움이 될 구체적인 제안
|
||||
4. 신뢰도 점수는 0.0-1.0 사이의 값
|
||||
""",
|
||||
reviewsText
|
||||
);
|
||||
|
||||
return callOpenAI(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI API 호출
|
||||
*/
|
||||
private String callOpenAI(String prompt) {
|
||||
if (openaiApiKey == null || openaiApiKey.trim().isEmpty() || openaiApiKey.equals("your-openai-api-key")) {
|
||||
throw new RuntimeException("OpenAI API 키가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
// 요청 헤더 설정
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.setBearerAuth(openaiApiKey);
|
||||
|
||||
// 요청 바디 생성
|
||||
OpenAIRequest request = OpenAIRequest.builder()
|
||||
.model(openaiModel)
|
||||
.messages(List.of(
|
||||
OpenAIMessage.builder()
|
||||
.role("user")
|
||||
.content(prompt)
|
||||
.build()
|
||||
))
|
||||
.maxTokens(1500)
|
||||
.temperature(0.7)
|
||||
.build();
|
||||
|
||||
String requestBody = objectMapper.writeValueAsString(request);
|
||||
HttpEntity<String> entity = new HttpEntity<>(requestBody, headers);
|
||||
|
||||
// API 호출
|
||||
String url = openaiBaseUrl + "/chat/completions";
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
url,
|
||||
HttpMethod.POST,
|
||||
entity,
|
||||
String.class
|
||||
);
|
||||
|
||||
// 응답 파싱
|
||||
return parseOpenAIResponse(response.getBody());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("OpenAI API 호출 실패", e);
|
||||
throw new RuntimeException("OpenAI API 호출에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI 응답 파싱
|
||||
*/
|
||||
private String parseOpenAIResponse(String responseBody) {
|
||||
try {
|
||||
Map<String, Object> response = objectMapper.readValue(responseBody, Map.class);
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
|
||||
|
||||
if (choices != null && !choices.isEmpty()) {
|
||||
Map<String, Object> message = (Map<String, Object>) choices.get(0).get("message");
|
||||
return (String) message.get("content");
|
||||
}
|
||||
|
||||
throw new RuntimeException("OpenAI 응답에서 내용을 찾을 수 없습니다.");
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
log.error("OpenAI 응답 파싱 실패", e);
|
||||
throw new RuntimeException("OpenAI 응답 파싱에 실패했습니다.", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분석 결과를 AiFeedback 객체로 파싱
|
||||
*/
|
||||
private AiFeedback parseAnalysisResult(String analysisResult, int totalReviews) {
|
||||
try {
|
||||
// JSON 형태로 응답이 왔다고 가정하고 파싱
|
||||
Map<String, Object> result = objectMapper.readValue(analysisResult, Map.class);
|
||||
|
||||
return AiFeedback.builder()
|
||||
.summary((String) result.get("summary"))
|
||||
.positivePoints((List<String>) result.get("positivePoints"))
|
||||
.improvementPoints((List<String>) result.get("improvementPoints"))
|
||||
.recommendations((List<String>) result.get("recommendations"))
|
||||
.sentimentAnalysis((String) result.get("sentimentAnalysis"))
|
||||
.confidenceScore(((Number) result.get("confidenceScore")).doubleValue())
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("OpenAI 분석 결과 파싱 실패, 기본 분석 수행", e);
|
||||
return performBasicAnalysis(analysisResult, totalReviews);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 분석 수행 (파싱 실패 시 fallback)
|
||||
*/
|
||||
private AiFeedback performBasicAnalysis(String analysisResult, int totalReviews) {
|
||||
return AiFeedback.builder()
|
||||
.summary(String.format("총 %d개의 리뷰를 AI로 분석했습니다.", totalReviews))
|
||||
.positivePoints(Arrays.asList("고객 서비스", "음식 품질", "매장 분위기"))
|
||||
.improvementPoints(Arrays.asList("대기시간 단축", "메뉴 다양성", "가격 경쟁력"))
|
||||
.recommendations(Arrays.asList("고객 피드백 적극 반영", "서비스 교육 강화", "매장 환경 개선"))
|
||||
.sentimentAnalysis("전반적으로 긍정적인 평가")
|
||||
.confidenceScore(0.75)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 실행 계획 파싱
|
||||
*/
|
||||
private List<String> parseActionPlans(String result) {
|
||||
// 숫자로 시작하는 라인들을 찾아서 실행 계획으로 추출
|
||||
String[] lines = result.split("\n");
|
||||
return Arrays.stream(lines)
|
||||
.filter(line -> line.matches("^\\d+\\..*"))
|
||||
.map(line -> line.replaceFirst("^\\d+\\.\\s*", "").trim())
|
||||
.filter(line -> !line.isEmpty())
|
||||
.limit(5) // 최대 5개까지
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 피드백 생성
|
||||
*/
|
||||
private AiFeedback createEmptyFeedback() {
|
||||
return AiFeedback.builder()
|
||||
.summary("분석할 리뷰 데이터가 없습니다.")
|
||||
.positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가"))
|
||||
.improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요"))
|
||||
.recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행"))
|
||||
.sentimentAnalysis("데이터 부족")
|
||||
.confidenceScore(0.0)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
.summary("분석할 리뷰 데이터가 없습니다.")
|
||||
.positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가"))
|
||||
.improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요"))
|
||||
.recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행"))
|
||||
.sentimentAnalysis("데이터 부족")
|
||||
.confidenceScore(0.0)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback 피드백 생성 (OpenAI 호출 실패 시)
|
||||
*/
|
||||
private AiFeedback createFallbackFeedback(List<String> reviewData) {
|
||||
log.warn("OpenAI 호출 실패로 fallback 분석 수행");
|
||||
|
||||
// 간단한 키워드 기반 분석
|
||||
long positiveCount = reviewData.stream()
|
||||
.mapToLong(review -> countPositiveKeywords(review))
|
||||
.sum();
|
||||
|
||||
long negativeCount = reviewData.stream()
|
||||
.mapToLong(review -> countNegativeKeywords(review))
|
||||
.sum();
|
||||
|
||||
double positiveRate = positiveCount > 0 ? (double) positiveCount / (positiveCount + negativeCount) * 100 : 50.0;
|
||||
|
||||
return AiFeedback.builder()
|
||||
.summary(String.format("총 %d개의 리뷰를 분석했습니다. (간편 분석)", reviewData.size()))
|
||||
.positivePoints(Arrays.asList("서비스", "맛", "분위기"))
|
||||
.improvementPoints(Arrays.asList("대기시간", "가격", "청결도"))
|
||||
.recommendations(Arrays.asList("고객 의견 수렴", "서비스 개선", "품질 향상"))
|
||||
.sentimentAnalysis(String.format("긍정 비율: %.1f%%", positiveRate))
|
||||
.confidenceScore(0.6)
|
||||
.generatedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private long countPositiveKeywords(String review) {
|
||||
String[] positiveWords = {"좋", "맛있", "친절", "깨끗", "만족", "추천", "최고"};
|
||||
return Arrays.stream(positiveWords)
|
||||
.mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0)
|
||||
.sum();
|
||||
}
|
||||
|
||||
private long countNegativeKeywords(String review) {
|
||||
String[] negativeWords = {"나쁘", "맛없", "불친절", "더럽", "실망", "최악", "별로"};
|
||||
return Arrays.stream(negativeWords)
|
||||
.mapToLong(word -> review.toLowerCase().contains(word) ? 1 : 0)
|
||||
.sum();
|
||||
}
|
||||
|
||||
// OpenAI API 요청/응답 DTO 클래스들
|
||||
@Data
|
||||
@lombok.Builder
|
||||
private static class OpenAIRequest {
|
||||
private String model;
|
||||
private List<OpenAIMessage> messages;
|
||||
@JsonProperty("max_tokens")
|
||||
private Integer maxTokens;
|
||||
private Double temperature;
|
||||
}
|
||||
|
||||
@Data
|
||||
@lombok.Builder
|
||||
private static class OpenAIMessage {
|
||||
private String role;
|
||||
private String content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 요약 생성
|
||||
|
||||
@ -1,14 +1,27 @@
|
||||
package com.ktds.hi.analytics.infra.gateway;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 외부 리뷰 서비스 어댑터 클래스
|
||||
@ -30,11 +43,20 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
|
||||
String[] reviewArray = restTemplate.getForObject(url, String[].class);
|
||||
|
||||
List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
|
||||
// ReviewListResponse 배열로 직접 받기 (Review 서비스가 List<ReviewListResponse> 반환)
|
||||
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||
|
||||
if (reviewArray == null || reviewArray.length == 0) {
|
||||
log.info("매장에 리뷰가 없습니다: storeId={}", storeId);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// ReviewListResponse에서 content만 추출
|
||||
List<String> reviews = Arrays.stream(reviewArray)
|
||||
.map(ReviewListResponse::getContent)
|
||||
.filter(content -> content != null && !content.trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
|
||||
|
||||
return reviews;
|
||||
|
||||
} catch (Exception e) {
|
||||
@ -49,13 +71,30 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
|
||||
log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days;
|
||||
String[] reviewArray = restTemplate.getForObject(url, String[].class);
|
||||
// String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days;
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "?size=100";
|
||||
// ReviewListResponse 배열로 직접 받기
|
||||
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||
|
||||
if (reviewArray == null || reviewArray.length == 0) {
|
||||
log.info("매장에 최근 리뷰가 없습니다: storeId={}", storeId);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 최근 N일 이내의 리뷰만 필터링
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days);
|
||||
|
||||
List<String> recentReviews = Arrays.stream(reviewArray)
|
||||
.filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate))
|
||||
.map(ReviewListResponse::getContent)
|
||||
.filter(content -> content != null && !content.trim().isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
|
||||
|
||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size());
|
||||
|
||||
List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
|
||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
|
||||
|
||||
return reviews;
|
||||
return recentReviews;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e);
|
||||
@ -125,4 +164,74 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
|
||||
"다음에도 주문할게요!"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@Data
|
||||
public static class ReviewListResponse {
|
||||
|
||||
@JsonProperty("reviewId")
|
||||
private Long reviewId;
|
||||
|
||||
@JsonProperty("memberNickname")
|
||||
private String memberNickname;
|
||||
|
||||
@JsonProperty("rating")
|
||||
private Integer rating;
|
||||
|
||||
@JsonProperty("content")
|
||||
private String content;
|
||||
|
||||
@JsonProperty("imageUrls")
|
||||
private List<String> imageUrls;
|
||||
|
||||
@JsonProperty("likeCount")
|
||||
private Integer likeCount;
|
||||
|
||||
@JsonProperty("dislikeCount")
|
||||
private Integer dislikeCount;
|
||||
|
||||
@JsonProperty("createdAt")
|
||||
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 다양한 LocalDateTime 형식을 처리하는 커스텀 Deserializer
|
||||
*/
|
||||
public static class FlexibleLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
|
||||
|
||||
private static final DateTimeFormatter[] FORMATTERS = {
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS"), // 마이크로초 6자리
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSS"), // 마이크로초 5자리
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSS"), // 마이크로초 4자리
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // 밀리초 3자리
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SS"), // 2자리
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.S"), // 1자리
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), // 초까지만
|
||||
DateTimeFormatter.ISO_LOCAL_DATE_TIME // ISO 표준
|
||||
};
|
||||
|
||||
@Override
|
||||
public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
String dateString = parser.getText();
|
||||
|
||||
if (dateString == null || dateString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 여러 형식으로 시도
|
||||
for (DateTimeFormatter formatter : FORMATTERS) {
|
||||
try {
|
||||
return LocalDateTime.parse(dateString, formatter);
|
||||
} catch (DateTimeParseException e) {
|
||||
// 다음 형식으로 시도
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 형식이 실패하면 현재 시간 반환 (에러 로그)
|
||||
System.err.println("Failed to parse LocalDateTime: " + dateString + ", using current time");
|
||||
return LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ ai-api:
|
||||
claude:
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
base-url: https://api.anthropic.com
|
||||
model: claude-3-sonnet-20240229
|
||||
model: claude-sonnet-4-20250514
|
||||
|
||||
#external-api:
|
||||
# openai:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user