store tag insert
This commit is contained in:
parent
e4d87cc98a
commit
496e11e43c
@ -1,46 +1,46 @@
|
||||
package com.ktds.hi.analytics.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 서비스 포트 인터페이스
|
||||
* 외부 AI API 연동을 위한 출력 포트
|
||||
*/
|
||||
public interface AIServicePort {
|
||||
|
||||
/**
|
||||
* AI 피드백 생성
|
||||
*/
|
||||
AiFeedback generateFeedback(List<String> reviewData);
|
||||
|
||||
/**
|
||||
* 감정 분석
|
||||
*/
|
||||
SentimentType analyzeSentiment(String content);
|
||||
|
||||
/**
|
||||
* 대량 리뷰 감정 분석 (새로 추가)
|
||||
* 여러 리뷰를 한 번에 분석하여 긍정/부정/중립 개수 반환
|
||||
*
|
||||
* @param reviews 분석할 리뷰 목록
|
||||
* @return 감정 타입별 개수 맵
|
||||
*/
|
||||
Map<SentimentType, Integer> analyzeBulkSentiments(List<String> reviews);
|
||||
|
||||
/**
|
||||
* 실행 계획 생성
|
||||
*/
|
||||
List<String> generateActionPlan(List<String> actionPlanSelect, AiFeedback feedback);
|
||||
|
||||
// 🔥 고객용 긍정 리뷰 요약 생성 메서드 추가
|
||||
/**
|
||||
* 긍정적인 리뷰만을 분석하여 고객용 요약 생성
|
||||
* @param positiveReviews 긍정적인 리뷰 목록
|
||||
* @return 고객에게 보여줄 긍정적인 요약
|
||||
*/
|
||||
String generateCustomerPositiveSummary(List<String> positiveReviews);
|
||||
}
|
||||
package com.ktds.hi.analytics.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI 서비스 포트 인터페이스
|
||||
* 외부 AI API 연동을 위한 출력 포트
|
||||
*/
|
||||
public interface AIServicePort {
|
||||
|
||||
/**
|
||||
* AI 피드백 생성
|
||||
*/
|
||||
AiFeedback generateFeedback(List<String> reviewData);
|
||||
|
||||
/**
|
||||
* 감정 분석
|
||||
*/
|
||||
SentimentType analyzeSentiment(String content);
|
||||
|
||||
/**
|
||||
* 대량 리뷰 감정 분석 (새로 추가)
|
||||
* 여러 리뷰를 한 번에 분석하여 긍정/부정/중립 개수 반환
|
||||
*
|
||||
* @param reviews 분석할 리뷰 목록
|
||||
* @return 감정 타입별 개수 맵
|
||||
*/
|
||||
Map<SentimentType, Integer> analyzeBulkSentiments(List<String> reviews);
|
||||
|
||||
/**
|
||||
* 실행 계획 생성
|
||||
*/
|
||||
List<String> generateActionPlan(List<String> actionPlanSelect, AiFeedback feedback);
|
||||
|
||||
// 🔥 고객용 긍정 리뷰 요약 생성 메서드 추가
|
||||
/**
|
||||
* 긍정적인 리뷰만을 분석하여 고객용 요약 생성
|
||||
* @param positiveReviews 긍정적인 리뷰 목록
|
||||
* @return 고객에게 보여줄 긍정적인 요약
|
||||
*/
|
||||
String generateCustomerPositiveSummary(List<String> positiveReviews);
|
||||
}
|
||||
|
||||
@ -1,45 +1,45 @@
|
||||
package com.ktds.hi.analytics.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.Analytics;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 분석 데이터 포트 인터페이스
|
||||
* Clean Architecture의 출력 포트 정의
|
||||
*/
|
||||
public interface AnalyticsPort {
|
||||
|
||||
/**
|
||||
* 매장 ID로 분석 데이터 조회
|
||||
*/
|
||||
Optional<Analytics> findAnalyticsByStoreId(Long storeId);
|
||||
|
||||
/**
|
||||
* 분석 데이터 저장
|
||||
*/
|
||||
Analytics saveAnalytics(Analytics analytics);
|
||||
|
||||
/**
|
||||
* 매장 ID로 AI 피드백 조회
|
||||
*/
|
||||
Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId);
|
||||
|
||||
/**
|
||||
* 매장 ID로 AI 긍정 피드백 조회(고객용)
|
||||
*/
|
||||
Optional<AiFeedback> findPositiveAIFeedbackByStoreId(Long storeId);
|
||||
|
||||
|
||||
/**
|
||||
* AI 피드백 ID로 조회 (추가된 메서드)
|
||||
*/
|
||||
Optional<AiFeedback> findAIFeedbackById(Long feedbackId);
|
||||
|
||||
|
||||
/**
|
||||
* AI 피드백 저장
|
||||
*/
|
||||
AiFeedback saveAIFeedback(AiFeedback feedback);
|
||||
}
|
||||
package com.ktds.hi.analytics.biz.usecase.out;
|
||||
|
||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
||||
import com.ktds.hi.analytics.biz.domain.Analytics;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 분석 데이터 포트 인터페이스
|
||||
* Clean Architecture의 출력 포트 정의
|
||||
*/
|
||||
public interface AnalyticsPort {
|
||||
|
||||
/**
|
||||
* 매장 ID로 분석 데이터 조회
|
||||
*/
|
||||
Optional<Analytics> findAnalyticsByStoreId(Long storeId);
|
||||
|
||||
/**
|
||||
* 분석 데이터 저장
|
||||
*/
|
||||
Analytics saveAnalytics(Analytics analytics);
|
||||
|
||||
/**
|
||||
* 매장 ID로 AI 피드백 조회
|
||||
*/
|
||||
Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId);
|
||||
|
||||
/**
|
||||
* 매장 ID로 AI 긍정 피드백 조회(고객용)
|
||||
*/
|
||||
Optional<AiFeedback> findPositiveAIFeedbackByStoreId(Long storeId);
|
||||
|
||||
|
||||
/**
|
||||
* AI 피드백 ID로 조회 (추가된 메서드)
|
||||
*/
|
||||
Optional<AiFeedback> findAIFeedbackById(Long feedbackId);
|
||||
|
||||
|
||||
/**
|
||||
* AI 피드백 저장
|
||||
*/
|
||||
AiFeedback saveAIFeedback(AiFeedback feedback);
|
||||
}
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
package com.ktds.hi.analytics.biz.usecase.out;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 외부 리뷰 데이터 포트 인터페이스
|
||||
* 리뷰 서비스와의 연동을 위한 출력 포트
|
||||
*/
|
||||
public interface ExternalReviewPort {
|
||||
|
||||
/**
|
||||
* 매장의 리뷰 데이터 조회
|
||||
*/
|
||||
List<String> getReviewData(Long storeId);
|
||||
|
||||
/**
|
||||
* 최근 리뷰 데이터 조회
|
||||
*/
|
||||
List<String> getRecentReviews(Long storeId, Integer days);
|
||||
|
||||
// 🔥 긍정적인 리뷰만 조회하는 메서드 추가
|
||||
/**
|
||||
* 긍정적인 리뷰만 조회 (평점 4점 이상)
|
||||
* @param storeId 매장 ID
|
||||
* @param days 조회 기간 (일)
|
||||
* @return 긍정적인 리뷰 목록
|
||||
*/
|
||||
List<String> getPositiveReviews(Long storeId, Integer days);
|
||||
|
||||
/**
|
||||
* 리뷰 개수 조회
|
||||
*/
|
||||
Integer getReviewCount(Long storeId);
|
||||
|
||||
/**
|
||||
* 평균 평점 조회
|
||||
*/
|
||||
Double getAverageRating(Long storeId);
|
||||
}
|
||||
package com.ktds.hi.analytics.biz.usecase.out;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 외부 리뷰 데이터 포트 인터페이스
|
||||
* 리뷰 서비스와의 연동을 위한 출력 포트
|
||||
*/
|
||||
public interface ExternalReviewPort {
|
||||
|
||||
/**
|
||||
* 매장의 리뷰 데이터 조회
|
||||
*/
|
||||
List<String> getReviewData(Long storeId);
|
||||
|
||||
/**
|
||||
* 최근 리뷰 데이터 조회
|
||||
*/
|
||||
List<String> getRecentReviews(Long storeId, Integer days);
|
||||
|
||||
// 🔥 긍정적인 리뷰만 조회하는 메서드 추가
|
||||
/**
|
||||
* 긍정적인 리뷰만 조회 (평점 4점 이상)
|
||||
* @param storeId 매장 ID
|
||||
* @param days 조회 기간 (일)
|
||||
* @return 긍정적인 리뷰 목록
|
||||
*/
|
||||
List<String> getPositiveReviews(Long storeId, Integer days);
|
||||
|
||||
/**
|
||||
* 리뷰 개수 조회
|
||||
*/
|
||||
Integer getReviewCount(Long storeId);
|
||||
|
||||
/**
|
||||
* 평균 평점 조회
|
||||
*/
|
||||
Double getAverageRating(Long storeId);
|
||||
}
|
||||
|
||||
@ -1,31 +1,31 @@
|
||||
package com.ktds.hi.analytics.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 고객용 긍정 리뷰 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "고객용 긍정 리뷰 응답")
|
||||
public class CustomerPositiveReviewResponse {
|
||||
|
||||
@Schema(description = "매장 ID")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "긍정적인 리뷰 요약")
|
||||
private String positiveSummary;
|
||||
|
||||
@Schema(description = "분석 일시")
|
||||
private LocalDateTime analyzedAt;
|
||||
}
|
||||
|
||||
package com.ktds.hi.analytics.infra.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 고객용 긍정 리뷰 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "고객용 긍정 리뷰 응답")
|
||||
public class CustomerPositiveReviewResponse {
|
||||
|
||||
@Schema(description = "매장 ID")
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "긍정적인 리뷰 요약")
|
||||
private String positiveSummary;
|
||||
|
||||
@Schema(description = "분석 일시")
|
||||
private LocalDateTime analyzedAt;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,293 +1,293 @@
|
||||
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.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 외부 리뷰 서비스 어댑터 클래스
|
||||
* 리뷰 서비스와의 API 통신을 담당
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalReviewAdapter implements ExternalReviewPort {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${external.services.review}")
|
||||
private String reviewServiceUrl;
|
||||
|
||||
@Override
|
||||
public List<String> getReviewData(Long storeId) {
|
||||
log.info("리뷰 데이터 조회: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
|
||||
// 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) {
|
||||
log.error("리뷰 데이터 조회 실패: storeId={}", storeId, e);
|
||||
// 실패 시 더미 데이터 반환
|
||||
return getDummyReviewData(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getRecentReviews(Long storeId, Integer days) {
|
||||
log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
|
||||
|
||||
try {
|
||||
|
||||
//최근 데이터를 가져오도록 변경
|
||||
// String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId + "?size=100&days=" + days;
|
||||
//
|
||||
// // ReviewListResponse 배열로 직접 받기
|
||||
// ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||
|
||||
int totalSize = 200;
|
||||
int threadCount = 4;
|
||||
int pageSize = totalSize / threadCount; // 50개씩
|
||||
|
||||
// ExecutorService 생성
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
|
||||
List<CompletableFuture<ReviewListResponse[]>> futures = new ArrayList<>();
|
||||
|
||||
// 4개의 비동기 요청 생성 (limit 50, offset 0/50/100/150)
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int offset = i * pageSize;
|
||||
final int limit = pageSize;
|
||||
|
||||
CompletableFuture<ReviewListResponse[]> future = CompletableFuture.supplyAsync(() -> {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId
|
||||
+ "?size=" + limit + "&offset=" + offset + "&days=" + days;
|
||||
|
||||
log.debug("스레드 {}에서 URL 호출: {}", Thread.currentThread().getName(), url);
|
||||
|
||||
// 기존과 동일한 방식으로 API 호출
|
||||
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||
|
||||
if (reviewArray == null) {
|
||||
log.debug("스레드 {}에서 빈 응답 수신", Thread.currentThread().getName());
|
||||
return new ReviewListResponse[0];
|
||||
}
|
||||
|
||||
log.debug("스레드 {}에서 {} 개 리뷰 수신", Thread.currentThread().getName(), reviewArray.length);
|
||||
return reviewArray;
|
||||
|
||||
}, executorService);
|
||||
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
// 모든 요청 완료 대기 및 결과 합치기
|
||||
List<ReviewListResponse> allReviewResponses = new ArrayList<>();
|
||||
for (CompletableFuture<ReviewListResponse[]> future : futures) {
|
||||
ReviewListResponse[] reviewArray = future.get(30, TimeUnit.SECONDS); // 30초 타임아웃
|
||||
allReviewResponses.addAll(Arrays.asList(reviewArray));
|
||||
}
|
||||
|
||||
executorService.shutdown();
|
||||
|
||||
|
||||
// 최근 N일 이내의 리뷰만 필터링
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days);
|
||||
|
||||
List<String> recentReviews = allReviewResponses.stream()
|
||||
.filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate))
|
||||
.map(ReviewListResponse::getContent)
|
||||
.filter(content -> content != null && !content.trim().isEmpty())
|
||||
.map(content -> content.replace("`", "")
|
||||
.replace("\n", "")
|
||||
.replace("\\", "")
|
||||
.replace("\"", ""))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
|
||||
|
||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size());
|
||||
|
||||
return recentReviews;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e);
|
||||
return getDummyRecentReviews(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getPositiveReviews(Long storeId, Integer days) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getReviewCount(Long storeId) {
|
||||
log.info("리뷰 개수 조회: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/count";
|
||||
Integer count = restTemplate.getForObject(url, Integer.class);
|
||||
|
||||
log.info("리뷰 개수 조회 완료: storeId={}, count={}", storeId, count);
|
||||
return count != null ? count : 0;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("리뷰 개수 조회 실패: storeId={}", storeId, e);
|
||||
return 25; // 더미 값
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double getAverageRating(Long storeId) {
|
||||
log.info("평균 평점 조회: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/average-rating";
|
||||
Double rating = restTemplate.getForObject(url, Double.class);
|
||||
|
||||
log.info("평균 평점 조회 완료: storeId={}, rating={}", storeId, rating);
|
||||
return rating != null ? rating : 0.0;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("평균 평점 조회 실패: storeId={}", storeId, e);
|
||||
return 4.2; // 더미 값
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 더미 리뷰 데이터 생성
|
||||
*/
|
||||
private List<String> getDummyReviewData(Long storeId) {
|
||||
return Arrays.asList(
|
||||
"음식이 정말 맛있어요! 배달도 빨랐습니다.",
|
||||
"가격 대비 양이 많고 맛도 좋네요. 추천합니다.",
|
||||
"배달 시간이 너무 오래 걸렸어요. 음식은 괜찮았습니다.",
|
||||
"포장 상태가 별로였어요. 국물이 새어나왔습니다.",
|
||||
"직원분들이 친절하고 음식도 맛있어요. 재주문 할게요!",
|
||||
"메뉴가 다양하고 맛있습니다. 자주 이용할 것 같아요.",
|
||||
"가격이 조금 비싸긴 하지만 맛은 좋아요.",
|
||||
"배달 기사님이 친절하셨어요. 음식도 따뜻했습니다."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 더미 최근 리뷰 데이터 생성
|
||||
*/
|
||||
private List<String> getDummyRecentReviews(Long storeId) {
|
||||
return Arrays.asList(
|
||||
"어제 주문했는데 정말 맛있었어요!",
|
||||
"배달이 빨라서 좋았습니다.",
|
||||
"음식 온도가 적절했어요.",
|
||||
"포장이 깔끔하게 되어있었습니다.",
|
||||
"다음에도 주문할게요!"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 외부 리뷰 서비스 어댑터 클래스
|
||||
* 리뷰 서비스와의 API 통신을 담당
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalReviewAdapter implements ExternalReviewPort {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@Value("${external.services.review}")
|
||||
private String reviewServiceUrl;
|
||||
|
||||
@Override
|
||||
public List<String> getReviewData(Long storeId) {
|
||||
log.info("리뷰 데이터 조회: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
|
||||
// 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) {
|
||||
log.error("리뷰 데이터 조회 실패: storeId={}", storeId, e);
|
||||
// 실패 시 더미 데이터 반환
|
||||
return getDummyReviewData(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getRecentReviews(Long storeId, Integer days) {
|
||||
log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
|
||||
|
||||
try {
|
||||
|
||||
//최근 데이터를 가져오도록 변경
|
||||
// String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId + "?size=100&days=" + days;
|
||||
//
|
||||
// // ReviewListResponse 배열로 직접 받기
|
||||
// ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||
|
||||
int totalSize = 200;
|
||||
int threadCount = 4;
|
||||
int pageSize = totalSize / threadCount; // 50개씩
|
||||
|
||||
// ExecutorService 생성
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
|
||||
List<CompletableFuture<ReviewListResponse[]>> futures = new ArrayList<>();
|
||||
|
||||
// 4개의 비동기 요청 생성 (limit 50, offset 0/50/100/150)
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int offset = i * pageSize;
|
||||
final int limit = pageSize;
|
||||
|
||||
CompletableFuture<ReviewListResponse[]> future = CompletableFuture.supplyAsync(() -> {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/recent/" + storeId
|
||||
+ "?size=" + limit + "&offset=" + offset + "&days=" + days;
|
||||
|
||||
log.debug("스레드 {}에서 URL 호출: {}", Thread.currentThread().getName(), url);
|
||||
|
||||
// 기존과 동일한 방식으로 API 호출
|
||||
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||
|
||||
if (reviewArray == null) {
|
||||
log.debug("스레드 {}에서 빈 응답 수신", Thread.currentThread().getName());
|
||||
return new ReviewListResponse[0];
|
||||
}
|
||||
|
||||
log.debug("스레드 {}에서 {} 개 리뷰 수신", Thread.currentThread().getName(), reviewArray.length);
|
||||
return reviewArray;
|
||||
|
||||
}, executorService);
|
||||
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
// 모든 요청 완료 대기 및 결과 합치기
|
||||
List<ReviewListResponse> allReviewResponses = new ArrayList<>();
|
||||
for (CompletableFuture<ReviewListResponse[]> future : futures) {
|
||||
ReviewListResponse[] reviewArray = future.get(30, TimeUnit.SECONDS); // 30초 타임아웃
|
||||
allReviewResponses.addAll(Arrays.asList(reviewArray));
|
||||
}
|
||||
|
||||
executorService.shutdown();
|
||||
|
||||
|
||||
// 최근 N일 이내의 리뷰만 필터링
|
||||
LocalDateTime cutoffDate = LocalDateTime.now().minusDays(days);
|
||||
|
||||
List<String> recentReviews = allReviewResponses.stream()
|
||||
.filter(review -> review.getCreatedAt() != null && review.getCreatedAt().isAfter(cutoffDate))
|
||||
.map(ReviewListResponse::getContent)
|
||||
.filter(content -> content != null && !content.trim().isEmpty())
|
||||
.map(content -> content.replace("`", "")
|
||||
.replace("\n", "")
|
||||
.replace("\\", "")
|
||||
.replace("\"", ""))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
|
||||
|
||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, recentReviews.size());
|
||||
|
||||
return recentReviews;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, e);
|
||||
return getDummyRecentReviews(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getPositiveReviews(Long storeId, Integer days) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getReviewCount(Long storeId) {
|
||||
log.info("리뷰 개수 조회: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/count";
|
||||
Integer count = restTemplate.getForObject(url, Integer.class);
|
||||
|
||||
log.info("리뷰 개수 조회 완료: storeId={}, count={}", storeId, count);
|
||||
return count != null ? count : 0;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("리뷰 개수 조회 실패: storeId={}", storeId, e);
|
||||
return 25; // 더미 값
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double getAverageRating(Long storeId) {
|
||||
log.info("평균 평점 조회: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/average-rating";
|
||||
Double rating = restTemplate.getForObject(url, Double.class);
|
||||
|
||||
log.info("평균 평점 조회 완료: storeId={}, rating={}", storeId, rating);
|
||||
return rating != null ? rating : 0.0;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("평균 평점 조회 실패: storeId={}", storeId, e);
|
||||
return 4.2; // 더미 값
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 더미 리뷰 데이터 생성
|
||||
*/
|
||||
private List<String> getDummyReviewData(Long storeId) {
|
||||
return Arrays.asList(
|
||||
"음식이 정말 맛있어요! 배달도 빨랐습니다.",
|
||||
"가격 대비 양이 많고 맛도 좋네요. 추천합니다.",
|
||||
"배달 시간이 너무 오래 걸렸어요. 음식은 괜찮았습니다.",
|
||||
"포장 상태가 별로였어요. 국물이 새어나왔습니다.",
|
||||
"직원분들이 친절하고 음식도 맛있어요. 재주문 할게요!",
|
||||
"메뉴가 다양하고 맛있습니다. 자주 이용할 것 같아요.",
|
||||
"가격이 조금 비싸긴 하지만 맛은 좋아요.",
|
||||
"배달 기사님이 친절하셨어요. 음식도 따뜻했습니다."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 더미 최근 리뷰 데이터 생성
|
||||
*/
|
||||
private List<String> getDummyRecentReviews(Long storeId) {
|
||||
return Arrays.asList(
|
||||
"어제 주문했는데 정말 맛있었어요!",
|
||||
"배달이 빨라서 좋았습니다.",
|
||||
"음식 온도가 적절했어요.",
|
||||
"포장이 깔끔하게 되어있었습니다.",
|
||||
"다음에도 주문할게요!"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
logs/recommend-service.log.2025-06-18.0.gz
Normal file
BIN
logs/recommend-service.log.2025-06-18.0.gz
Normal file
Binary file not shown.
1
nano.save.1
Normal file
1
nano.save.1
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -94,7 +94,10 @@ public class StoreService implements StoreUseCase {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getAllTags(Long storeId){
|
||||
return storeJpaRepository.findById(storeId).getTagsJson();
|
||||
}
|
||||
@Override
|
||||
public List<StoreListResponse> getAllStores() {
|
||||
|
||||
@ -109,6 +112,7 @@ public class StoreService implements StoreUseCase {
|
||||
.rating(store.getRating())
|
||||
.reviewCount(store.getReviewCount())
|
||||
.status("운영중")
|
||||
.tagJson(store.getTagsJson())
|
||||
.imageUrl(store.getImageUrl())
|
||||
.operatingHours(store.getOperatingHours())
|
||||
.build())
|
||||
|
||||
@ -33,6 +33,8 @@ public interface StoreUseCase {
|
||||
|
||||
List<StoreListResponse> getAllStores();
|
||||
|
||||
String getAllTags(Long storeId);
|
||||
|
||||
/**
|
||||
* 매장 상세 조회
|
||||
*
|
||||
|
||||
@ -41,7 +41,6 @@ import java.util.List;
|
||||
public class StoreController {
|
||||
|
||||
private final StoreUseCase storeUseCase;
|
||||
private final StoreService storeService;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final MenuUseCase menuUseCase;
|
||||
|
||||
@ -80,6 +79,15 @@ public class StoreController {
|
||||
return ResponseEntity.ok(ApiResponse.success(responses));
|
||||
}
|
||||
|
||||
@GetMapping("/stores/{storeId}/tags")
|
||||
@Operation(summary = "매장 전체 리스트")
|
||||
public ResponseEntity<String> getStoreTags(@PathVariable Long storeId) {
|
||||
|
||||
String tagsJson = storeUseCase.getAllTags(storeId);
|
||||
return ResponseEntity.ok(tagsJson);
|
||||
}
|
||||
|
||||
|
||||
@Operation(summary = "매장 상세 조회", description = "매장의 상세 정보를 조회합니다.")
|
||||
@GetMapping("/{storeId}")
|
||||
public ResponseEntity<ApiResponse<StoreDetailResponse>> getStoreDetail(
|
||||
|
||||
@ -20,6 +20,9 @@ public class StoreListResponse {
|
||||
@Schema(description = "매장명", example = "맛집 한번 가볼래?")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "태그 리스트", example = "태그태그태그")
|
||||
private String tagJson;
|
||||
|
||||
@Schema(description = "주소", example = "서울시 강남구 테헤란로 123")
|
||||
private String address;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -197,4 +197,6 @@ public class StoreEntity {
|
||||
public boolean hasReviews() {
|
||||
return this.reviewCount != null && this.reviewCount > 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -30,6 +30,7 @@ public interface StoreJpaRepository extends JpaRepository<StoreEntity, Long> {
|
||||
*/
|
||||
List<StoreEntity> findByOwnerId(Long ownerId);
|
||||
|
||||
|
||||
/**
|
||||
* 매장 ID와 점주 ID로 매장 조회
|
||||
*/
|
||||
@ -145,4 +146,6 @@ public interface StoreJpaRepository extends JpaRepository<StoreEntity, Long> {
|
||||
@Param("minRating") Double minRating,
|
||||
@Param("keyword") String keyword,
|
||||
Pageable pageable);
|
||||
|
||||
StoreEntity findById(Long id);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user