This commit is contained in:
UNGGU0704 2025-06-16 17:17:36 +09:00
commit 2681382b59
29 changed files with 1237 additions and 154 deletions

View File

@ -1,7 +1,9 @@
package com.ktds.hi.analytics.biz.service;
import com.ktds.hi.analytics.biz.domain.ActionPlan;
import com.ktds.hi.analytics.biz.domain.Analytics;
import com.ktds.hi.analytics.biz.domain.AiFeedback;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import com.ktds.hi.analytics.biz.usecase.in.AnalyticsUseCase;
import com.ktds.hi.analytics.biz.usecase.out.*;
import com.ktds.hi.analytics.infra.dto.*;
@ -23,7 +25,7 @@ import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Transactional
public class AnalyticsService implements AnalyticsUseCase {
private final AnalyticsPort analyticsPort;
@ -32,9 +34,10 @@ public class AnalyticsService implements AnalyticsUseCase {
private final OrderDataPort orderDataPort;
private final CachePort cachePort;
private final EventPort eventPort;
private final ActionPlanPort actionPlanPort; // 추가된 의존성
@Override
@Cacheable(value = "storeAnalytics", key = "#storeId")
// @Cacheable(value = "storeAnalytics", key = "#storeId")
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
@ -43,8 +46,15 @@ public class AnalyticsService implements AnalyticsUseCase {
String cacheKey = "analytics:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
return (StoreAnalyticsResponse) cachedResult.get();
Object cached = cachedResult.get();
// StoreAnalyticsResponse 타입인지 확인
if (cached instanceof StoreAnalyticsResponse) {
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
return (StoreAnalyticsResponse) cached;
}
// LinkedHashMap인 경우 스킵하고 DB에서 조회
log.debug("캐시 데이터 타입 불일치, DB에서 조회: storeId={}, type={}",
storeId, cached.getClass().getSimpleName());
}
// 2. 데이터베이스에서 기존 분석 데이터 조회
@ -81,11 +91,23 @@ public class AnalyticsService implements AnalyticsUseCase {
// ... 나머지 메서드들은 이전과 동일 ...
@Override
@Cacheable(value = "aiFeedback", key = "#storeId")
// @Cacheable(value = "aiFeedback", key = "#storeId")
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
try {
// 1. 캐시에서 먼저 확인 (타입 안전성 보장)
String cacheKey = "ai_feedback_detail:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
Object cached = cachedResult.get();
if (cached instanceof AiFeedbackDetailResponse) {
log.info("캐시에서 AI 피드백 반환: storeId={}", storeId);
return (AiFeedbackDetailResponse) cached;
}
log.debug("AI 피드백 캐시 데이터 타입 불일치, DB에서 조회: storeId={}", storeId);
}
// 1. 기존 AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
@ -96,6 +118,7 @@ public class AnalyticsService implements AnalyticsUseCase {
// 3. 응답 생성
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
.feedbackId(aiFeedback.get().getId())
.storeId(storeId)
.summary(aiFeedback.get().getSummary())
.positivePoints(aiFeedback.get().getPositivePoints())
@ -124,11 +147,15 @@ public class AnalyticsService implements AnalyticsUseCase {
try {
// 1. 캐시 생성
// 1. 캐시 생성 확인
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
return (StoreStatisticsResponse) cachedResult.get();
Object cached = cachedResult.get();
if (cached instanceof StoreStatisticsResponse) {
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
return (StoreStatisticsResponse) cached;
}
}
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
@ -168,7 +195,10 @@ public class AnalyticsService implements AnalyticsUseCase {
String cacheKey = "ai_feedback_summary:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
return (AiFeedbackSummaryResponse) cachedResult.get();
Object cached = cachedResult.get();
if (cached instanceof AiFeedbackSummaryResponse) {
return (AiFeedbackSummaryResponse) cached;
}
}
// 2. AI 피드백 조회
@ -219,7 +249,10 @@ public class AnalyticsService implements AnalyticsUseCase {
String cacheKey = "review_analysis:store:" + storeId;
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
if (cachedResult.isPresent()) {
return (ReviewAnalysisResponse) cachedResult.get();
Object cached = cachedResult.get();
if (cached instanceof ReviewAnalysisResponse) {
return (ReviewAnalysisResponse) cached;
}
}
// 2. 최근 리뷰 데이터 조회 (30일)
@ -440,20 +473,26 @@ public class AnalyticsService implements AnalyticsUseCase {
}
@Override
@Transactional
public List<String> generateActionPlansFromFeedback(Long feedbackId) {
log.info("실행계획 생성: feedbackId={}", feedbackId);
try {
// 1. AI 피드백 조회
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(feedbackId); // 실제로는 feedbackId로 조회하는 메서드 필요
var aiFeedback = analyticsPort.findAIFeedbackById(feedbackId);
if (aiFeedback.isEmpty()) {
throw new RuntimeException("AI 피드백을 찾을 수 없습니다: " + feedbackId);
}
AiFeedback feedback = aiFeedback.get();
// 2. 기존 AIServicePort.generateActionPlan 메서드 활용
List<String> actionPlans = aiServicePort.generateActionPlan(aiFeedback.get());
// 3. DB에 실행계획 저장
saveGeneratedActionPlansToDatabase(feedback, actionPlans);
log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size());
return actionPlans;
@ -475,6 +514,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);
@ -533,4 +574,49 @@ public class AnalyticsService implements AnalyticsUseCase {
.build();
}
/**
* 생성된 실행계획을 데이터베이스에 저장하는 메서드
* AI 피드백 기반으로 생성된 실행계획들을 ActionPlan 테이블에 저장
*/
private void saveGeneratedActionPlansToDatabase(AiFeedback feedback, List<String> actionPlans) {
if (actionPlans.isEmpty()) {
log.info("저장할 실행계획이 없습니다: storeId={}", feedback.getStoreId());
return;
}
log.info("실행계획 DB 저장 시작: storeId={}, feedbackId={}, planCount={}",
feedback.getStoreId(), feedback.getId(), actionPlans.size());
for (int i = 0; i < actionPlans.size(); i++) {
String planContent = actionPlans.get(i);
// ActionPlan 도메인 객체 생성 (기존 ActionPlanService의 패턴과 동일하게)
ActionPlan actionPlan = ActionPlan.builder()
.storeId(feedback.getStoreId())
.userId(1L) // AI가 생성한 계획이므로 userId는 null
.title("AI 추천 실행계획 " + (i + 1))
.description(planContent)
.period("1개월") // 기본 실행 기간
.status(PlanStatus.PLANNED)
.tasks(List.of(planContent)) // 생성된 계획을 tasks로 설정
.note("AI 피드백(ID: " + feedback.getId() + ")을 기반으로 자동 생성된 실행계획")
.createdAt(LocalDateTime.now())
.build();
try {
// ActionPlan 저장 (기존 ActionPlanPort 활용)
ActionPlan savedPlan = actionPlanPort.saveActionPlan(actionPlan);
log.info("실행계획 저장 완료: storeId={}, planId={}, title={}",
feedback.getStoreId(), savedPlan.getId(), savedPlan.getTitle());
} catch (Exception e) {
log.error("실행계획 저장 실패: storeId={}, title={}",
feedback.getStoreId(), actionPlan.getTitle(), e);
// 개별 저장 실패 시에도 다음 계획은 계속 저장 시도
}
}
log.info("실행계획 DB 저장 완료: storeId={}, 총 {}개 계획 저장",
feedback.getStoreId(), actionPlans.size());
}
}

View File

@ -25,7 +25,13 @@ public interface AnalyticsPort {
* 매장 ID로 AI 피드백 조회
*/
Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId);
/**
* AI 피드백 ID로 조회 (추가된 메서드)
*/
Optional<AiFeedback> findAIFeedbackById(Long feedbackId);
/**
* AI 피드백 저장
*/

View File

@ -25,7 +25,7 @@ public class AiAnalysisRequest {
@Builder.Default
private Integer days = 30;
@Schema(description = "실행계획 자동 생성 여부", example = "true")
@Schema(description = "실행계획 자동 생성 여부", example = "false")
@Builder.Default
private Boolean generateActionPlan = true;
private Boolean generateActionPlan = false;
}

View File

@ -16,7 +16,8 @@ import java.util.List;
@NoArgsConstructor
@AllArgsConstructor
public class AiFeedbackDetailResponse {
private Long feedbackId;
private Long storeId;
private String summary;
private List<String> positivePoints;

View File

@ -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;
}
/**
* 요약 생성

View File

@ -55,6 +55,12 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity);
return toAiFeedbackDomain(saved);
}
@Override
public Optional<AiFeedback> findAIFeedbackById(Long feedbackId) {
return aiFeedbackJpaRepository.findById(feedbackId)
.map(this::toAiFeedbackDomain);
}
/**
* Analytics Entity를 Domain으로 변환

View File

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

View File

@ -21,13 +21,13 @@ public interface AiFeedbackJpaRepository extends JpaRepository<AiFeedbackEntity,
* 매장 ID로 AI 피드백 조회 (최신순)
*/
Optional<AiFeedbackEntity> findByStoreId(Long storeId);
/**
* 매장 ID로 최신 AI 피드백 조회
*/
@Query("SELECT af FROM AiFeedbackEntity af WHERE af.storeId = :storeId ORDER BY af.generatedAt DESC")
@Query("SELECT af FROM AiFeedbackEntity af WHERE af.storeId = :storeId ORDER BY af.createdAt DESC LIMIT 1")
Optional<AiFeedbackEntity> findLatestByStoreId(@Param("storeId") Long storeId);
/**
* 특정 기간 이후 생성된 AI 피드백 조회
*/

View File

@ -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:

View File

@ -20,7 +20,7 @@ spring:
# Redis 설정
data:
redis:
host: ${REDIS_HOST:localhost}
host: ${REDIS_HOST:localhost} //로컬
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms

View File

@ -1,23 +1,48 @@
package com.ktds.hi.member.domain;
import java.util.Arrays;
/**
* 태그 유형 열거형
* 취향 태그의 카테고리를 정의
*/
public enum TagType {
CUISINE("음식 종류"),
FLAVOR(""),
DIETARY("식이 제한"),
TASTE(""),
ATMOSPHERE("분위기"),
PRICE("가격대");
ALLERGY("알러지"),
SERVICE("서비스"),
PRICE_RANGE("가격대"),
CUISINE_TYPE("음식 종류"),
HEALTH_INFO("건강 정보");
private final String description;
TagType(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
/**
* 설명으로 TagType 찾기
*/
public static TagType fromDescription(String description) {
for (TagType type : TagType.values()) {
if (type.description.equals(description)) {
return type;
}
}
throw new IllegalArgumentException("Unknown tag type description: " + description);
}
/**
* 모든 태그 타입 설명 목록 반환
*/
public static String[] getAllDescriptions() {
return Arrays.stream(TagType.values())
.map(TagType::getDescription)
.toArray(String[]::new);
}
}

View File

@ -1,5 +1,6 @@
package com.ktds.hi.member.domain;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@ -13,11 +14,12 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "taste_tag")
public class TasteTag {
private Long id;
private String tagName;
private TagType tagType;
private String description;
private TagType tagType; //카테고리
private String description; //매운맛, 짠맛
private Boolean isActive;
}

View File

@ -0,0 +1,21 @@
package com.ktds.hi.member.repository.entity;
public enum TagCategory {
TASTE(""), // 매운맛, 단맛, 짠맛
ATMOSPHERE("분위기"), // 깨끗한, 혼밥, 데이트
ALLERGY("알러지"), // 유제품, 견과류, 갑각류
SERVICE("서비스"), // 빠른서비스, 친절한, 조용한
PRICE("가격대"), // 저렴한, 합리적인, 가성비
FOOD_TYPE("음식 종류"), // 한식, 중식, 일식
HEALTH("건강 정보"); // 저염, 저당, 글루텐프리
private final String description;
TagCategory(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,37 @@
package com.ktds.hi.member.repository.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Table(name = "tags")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TagEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tag_name", nullable = false, length = 50)
private String tagName; // 매운맛, 깨끗한, 유제품
@Enumerated(EnumType.STRING)
@Column(name = "tag_category", nullable = false)
private TagCategory tagCategory; // TASTE, ATMOSPHERE, ALLERGY
@Column(name = "tag_color", length = 7)
private String tagColor; // #FF5722
@Column(name = "sort_order")
private Integer sortOrder;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
}

View File

@ -11,6 +11,7 @@ import lombok.NoArgsConstructor;
* 취향 태그 엔티티 클래스
* 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티
*/
// TasteTagEntity.java
@Entity
@Table(name = "taste_tags")
@Getter
@ -18,22 +19,32 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class TasteTagEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tag_name", unique = true, nullable = false, length = 50)
private String tagName;
@Enumerated(EnumType.STRING)
@Column(name = "tag_type", nullable = false)
private TagType tagType;
@Column(length = 200)
private String description;
private String tagName; // 매운맛, 단맛, 짠맛
@Column(name = "tag_color", length = 7)
private String tagColor; // #FF5722
@Column(name = "sort_order")
private Integer sortOrder;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
}
@Enumerated(EnumType.STRING)
@Column(name = "tag_type", nullable = false)
private TagType tagType;
@Column(length = 200)
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "tag_category", nullable = false)
private TagCategory tagCategory; // 추가된 필드
}

View File

@ -1,31 +1,30 @@
/*
*/
package com.ktds.hi.member.repository.jpa;
import com.ktds.hi.member.domain.TagType;
import com.ktds.hi.member.repository.entity.TagCategory;
import com.ktds.hi.member.repository.entity.TasteTagEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 취향 태그 JPA 리포지토리 인터페이스
* 취향 태그 데이터의 CRUD 작업을 담당
*/
@Repository
public interface TasteTagRepository extends JpaRepository<TasteTagEntity, Long> {
/**
* 활성화된 태그 목록 조회
*/
List<TasteTagEntity> findByIsActiveTrue();
/**
* 태그 유형별 태그 목록 조회
*/
List<TasteTagEntity> findByTagTypeAndIsActiveTrue(TagType tagType);
/**
* 태그명으로 태그 조회
*/
List<TasteTagEntity> findByIsActiveTrue();
List<TasteTagEntity> findByTagNameIn(List<String> tagNames);
}
List<TasteTagEntity> findByTagCategoryAndIsActiveTrue(TagCategory tagCategory);
List<TasteTagEntity> findByIsActiveTrueOrderBySortOrder();
Optional<TasteTagEntity> findByTagNameAndTagCategory(String tagName, TagCategory tagCategory);
boolean existsByTagNameAndTagCategory(String tagName, TagCategory tagCategory);
}

View File

@ -0,0 +1,64 @@
package com.ktds.hi.store.biz.service;
import com.ktds.hi.store.biz.usecase.in.TagUseCase;
import com.ktds.hi.store.biz.usecase.out.TagRepositoryPort;
import com.ktds.hi.store.domain.Tag;
import com.ktds.hi.store.infra.dto.response.TopClickedTagResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
/**
* 태그 서비스 클래스
* 태그 관련 비즈니스 로직을 구현
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class TagService implements TagUseCase {
private final TagRepositoryPort tagRepositoryPort;
@Override
public List<TopClickedTagResponse> getTopClickedTags() {
log.info("가장 많이 클릭된 상위 5개 태그 조회 시작");
List<Tag> topTags = tagRepositoryPort.findTopClickedTags();
AtomicInteger rank = new AtomicInteger(1);
List<TopClickedTagResponse> responses = topTags.stream()
.map(tag -> TopClickedTagResponse.builder()
.tagId(tag.getId())
.tagName(tag.getTagName())
.tagCategory(tag.getTagCategory().name())
.tagColor(tag.getTagColor())
.clickCount(tag.getClickCount())
.rank(rank.getAndIncrement())
.build())
.collect(Collectors.toList());
log.info("가장 많이 클릭된 상위 5개 태그 조회 완료: count={}", responses.size());
return responses;
}
@Override
@Transactional
public void recordTagClick(Long tagId) {
log.info("태그 클릭 이벤트 처리 시작: tagId={}", tagId);
Tag updatedTag = tagRepositoryPort.incrementTagClickCount(tagId);
log.info("태그 클릭 수 증가 완료: tagId={}, clickCount={}",
tagId, updatedTag.getClickCount());
}
}

View File

@ -0,0 +1,25 @@
package com.ktds.hi.store.biz.usecase.in;
import com.ktds.hi.store.infra.dto.response.TopClickedTagResponse;
import java.util.List;
/**
* 태그 유스케이스 인터페이스
* 태그 관련 비즈니스 로직을 정의
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
public interface TagUseCase {
/**
* 가장 많이 클릭된 상위 5개 태그 조회
*/
List<TopClickedTagResponse> getTopClickedTags();
/**
* 태그 클릭 이벤트 처리
*/
void recordTagClick(Long tagId);
}

View File

@ -14,6 +14,16 @@ import java.util.Optional;
*/
public interface StoreRepositoryPort {
/**
* 태그로 매장 검색 (OR 조건)
*/
List<Store> findStoresByTagNames(List<String> tagNames);
/**
* 모든 태그를 포함하는 매장 검색 (AND 조건)
*/
List<Store> findStoresByAllTagNames(List<String> tagNames);
/**
* 점주 ID로 매장 목록 조회
*

View File

@ -0,0 +1,47 @@
package com.ktds.hi.store.biz.usecase.out;
// store/src/main/java/com/ktds/hi/store/biz/usecase/out/TagRepositoryPort.java
import com.ktds.hi.store.domain.Tag;
import java.util.List;
import java.util.Optional;
/**
* 태그 리포지토리 포트 인터페이스
* 태그 데이터 영속성 기능을 정의
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
public interface TagRepositoryPort {
/**
* 활성화된 모든 태그 조회
*/
List<Tag> findAllActiveTags();
/**
* 태그 ID로 태그 조회
*/
Optional<Tag> findTagById(Long tagId);
/**
* 태그명으로 태그 조회
*/
Optional<Tag> findTagByName(String tagName);
/**
* 가장 많이 클릭된 상위 5개 태그 조회
*/
List<Tag> findTopClickedTags();
/**
* 태그 클릭 증가
*/
Tag incrementTagClickCount(Long tagId);
/**
* 태그 저장
*/
Tag saveTag(Tag tag);
}

View File

@ -0,0 +1,46 @@
package com.ktds.hi.store.domain;
import lombok.Builder;
import lombok.Getter;
/**
* 태그 도메인 클래스
* 매장 태그 정보를 나타냄
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Getter
@Builder
public class Tag {
private Long id;
private String tagName;
private TagCategory tagCategory;
private String tagColor;
private Integer sortOrder;
private Boolean isActive;
private Long clickCount;
/**
* 클릭 증가
*/
public Tag incrementClickCount() {
return Tag.builder()
.id(this.id)
.tagName(this.tagName)
.tagCategory(this.tagCategory)
.tagColor(this.tagColor)
.sortOrder(this.sortOrder)
.isActive(this.isActive)
.clickCount(this.clickCount != null ? this.clickCount + 1 : 1L)
.build();
}
/**
* 활성 상태 확인
*/
public boolean isActive() {
return Boolean.TRUE.equals(this.isActive);
}
}

View File

@ -0,0 +1,29 @@
package com.ktds.hi.store.domain;
/**
* 태그 카테고리 열거형 클래스
* 매장 태그의 분류를 정의
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
public enum TagCategory {
TASTE(""), // 매운맛, 단맛, 짠맛
ATMOSPHERE("분위기"), // 깨끗한, 혼밥, 데이트
ALLERGY("알러지"), // 유제품, 견과류, 갑각류
SERVICE("서비스"), // 빠른서비스, 친절한, 조용한
PRICE("가격대"), // 저렴한, 합리적인, 가성비
FOOD_TYPE("음식 종류"), // 한식, 중식, 일식
HEALTH("건강 정보"); // 저염, 저당, 글루텐프리
private final String description;
TagCategory(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}

View File

@ -0,0 +1,52 @@
package com.ktds.hi.store.infra.controller;
import com.ktds.hi.store.biz.usecase.in.TagUseCase;
import com.ktds.hi.store.infra.dto.response.TopClickedTagResponse;
import com.ktds.hi.common.dto.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 태그 컨트롤러 클래스
* 태그 관련 API 엔드포인트를 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@RestController
@RequestMapping("/api/stores/tags")
@RequiredArgsConstructor
@Tag(name = "태그 관리 API", description = "매장 태그 조회 및 통계 관련 API")
public class TagController {
private final TagUseCase tagUseCase;
/**
* 가장 많이 클릭된 상위 5개 태그 조회 API
*/
@GetMapping("/top-clicked")
@Operation(summary = "인기 태그 조회", description = "가장 많이 클릭된 상위 5개 태그를 조회합니다.")
public ResponseEntity<ApiResponse<List<TopClickedTagResponse>>> getTopClickedTags() {
List<TopClickedTagResponse> topTags = tagUseCase.getTopClickedTags();
return ResponseEntity.ok(ApiResponse.success(topTags));
}
/**
* 태그 클릭 이벤트 기록 API
*/
@PostMapping("/{tagId}/click")
@Operation(summary = "태그 클릭 기록", description = "태그 클릭 이벤트를 기록하고 클릭 수를 증가시킵니다.")
public ResponseEntity<ApiResponse<Void>> recordTagClick(@PathVariable Long tagId) {
tagUseCase.recordTagClick(tagId);
return ResponseEntity.ok(ApiResponse.success());
}
}

View File

@ -0,0 +1,22 @@
package com.ktds.hi.store.infra.dto.response;
import lombok.Builder;
import lombok.Getter;
/**
* 인기 태그 응답 DTO 클래스
* 가장 많이 클릭된 태그 정보를 전달
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Getter
@Builder
public class TopClickedTagResponse {
private Long tagId;
private String tagName;
private String tagCategory;
private String tagColor;
private Long clickCount;
private Integer rank;
}

View File

@ -38,6 +38,22 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort {
.collect(Collectors.toList());
}
@Override
public List<Store> findStoresByTagNames(List<String> tagNames) {
return storeJpaRepository.findByTagNamesIn(tagNames)
.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public List<Store> findStoresByAllTagNames(List<String> tagNames) {
return storeJpaRepository.findByAllTagNames(tagNames, tagNames.size())
.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Store> findStoreById(Long storeId) {
return storeJpaRepository.findById(storeId)

View File

@ -0,0 +1,124 @@
package com.ktds.hi.store.infra.gateway;
import com.ktds.hi.store.biz.usecase.out.TagRepositoryPort;
import com.ktds.hi.store.domain.Tag;
import com.ktds.hi.store.domain.TagCategory;
import com.ktds.hi.store.infra.gateway.entity.TagEntity;
import com.ktds.hi.store.infra.gateway.repository.TagJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 태그 리포지토리 어댑터 클래스
* TagRepositoryPort를 구현하여 태그 데이터 액세스 기능을 제공
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class TagRepositoryAdapter implements TagRepositoryPort {
private final TagJpaRepository tagJpaRepository;
@Override
public List<Tag> findAllActiveTags() {
log.info("활성화된 모든 태그 조회");
List<TagEntity> entities = tagJpaRepository.findByIsActiveTrueOrderByTagName();
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Tag> findTagById(Long tagId) {
log.info("태그 ID로 태그 조회: tagId={}", tagId);
return tagJpaRepository.findById(tagId)
.filter(entity -> Boolean.TRUE.equals(entity.getIsActive()))
.map(this::toDomain);
}
@Override
public Optional<Tag> findTagByName(String tagName) {
log.info("태그명으로 태그 조회: tagName={}", tagName);
return tagJpaRepository.findByTagNameAndIsActiveTrue(tagName)
.map(this::toDomain);
}
@Override
public List<Tag> findTopClickedTags() {
log.info("가장 많이 클릭된 상위 5개 태그 조회");
List<TagEntity> entities = tagJpaRepository.findTop5ByOrderByClickCountDesc(
PageRequest.of(0, 5)
);
return entities.stream()
.map(this::toDomain)
.collect(Collectors.toList());
}
@Override
public Tag incrementTagClickCount(Long tagId) {
log.info("태그 클릭 수 증가: tagId={}", tagId);
TagEntity entity = tagJpaRepository.findById(tagId)
.orElseThrow(() -> new IllegalArgumentException("태그를 찾을 수 없습니다: " + tagId));
entity.incrementClickCount();
TagEntity saved = tagJpaRepository.save(entity);
return toDomain(saved);
}
@Override
public Tag saveTag(Tag tag) {
log.info("태그 저장: tagName={}", tag.getTagName());
TagEntity entity = toEntity(tag);
TagEntity saved = tagJpaRepository.save(entity);
return toDomain(saved);
}
/**
* 엔티티를 도메인으로 변환
*/
private Tag toDomain(TagEntity entity) {
return Tag.builder()
.id(entity.getId())
.tagName(entity.getTagName())
.tagCategory(entity.getTagCategory())
.tagColor(entity.getTagColor())
.sortOrder(entity.getSortOrder())
.isActive(entity.getIsActive())
.clickCount(entity.getClickCount())
.build();
}
/**
* 도메인을 엔티티로 변환
*/
private TagEntity toEntity(Tag domain) {
return TagEntity.builder()
.id(domain.getId())
.tagName(domain.getTagName())
.tagCategory(domain.getTagCategory())
.tagColor(domain.getTagColor())
.sortOrder(domain.getSortOrder())
.isActive(domain.getIsActive())
.clickCount(domain.getClickCount())
.build();
}
}

View File

@ -0,0 +1,52 @@
package com.ktds.hi.store.infra.gateway.entity;
import jakarta.persistence.*;
import lombok.*;
import com.ktds.hi.store.domain.TagCategory;
/**
* 태그 엔티티 클래스
* 매장 태그 정보를 저장
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Entity
@Table(name = "tags")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TagEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tag_name", nullable = false, length = 50)
private String tagName; // 매운맛, 깨끗한, 유제품
@Enumerated(EnumType.STRING)
@Column(name = "tag_category", nullable = false)
private TagCategory tagCategory; // TASTE, ATMOSPHERE, ALLERGY
@Column(name = "tag_color", length = 7)
private String tagColor; // #FF5722
@Column(name = "sort_order")
private Integer sortOrder;
@Column(name = "is_active")
@Builder.Default
private Boolean isActive = true;
@Column(name = "click_count")
@Builder.Default
private Long clickCount = 0L;
/**
* 클릭 증가
*/
public void incrementClickCount() {
this.clickCount = this.clickCount != null ? this.clickCount + 1 : 1L;
}
}

View File

@ -21,6 +21,9 @@ import java.util.Optional;
@Repository
public interface StoreJpaRepository extends JpaRepository<StoreEntity, Long> {
@Query("SELECT s FROM StoreEntity s WHERE s.status = 'ACTIVE' ORDER BY s.rating DESC")
Page<StoreEntity> findAllByOrderByRatingDesc(Pageable pageable);
/**
* 점주 ID로 매장 목록 조회
*/
@ -49,9 +52,18 @@ public interface StoreJpaRepository extends JpaRepository<StoreEntity, Long> {
/**
* 평점 기준 내림차순으로 매장 조회
*/
@Query("SELECT s FROM StoreEntity s ORDER BY s.rating DESC")
Page<StoreEntity> findAllByOrderByRatingDesc(Pageable pageable);
@Query(value = "SELECT DISTINCT s.* FROM stores s " +
"WHERE EXISTS (SELECT 1 FROM store_tags st " +
"WHERE st.store_id = s.id AND st.tag_name IN :tagNames) " +
"AND s.status = 'ACTIVE'", nativeQuery = true)
List<StoreEntity> findByTagNamesIn(@Param("tagNames") List<String> tagNames);
@Query(value = "SELECT s.* FROM stores s " +
"WHERE (SELECT COUNT(DISTINCT st.tag_name) FROM store_tags st " +
"WHERE st.store_id = s.id AND st.tag_name IN :tagNames) = :tagCount " +
"AND s.status = 'ACTIVE'", nativeQuery = true)
List<StoreEntity> findByAllTagNames(@Param("tagNames") List<String> tagNames,
@Param("tagCount") Integer tagCount);
/**
* 점주별 매장 조회
*/

View File

@ -0,0 +1,49 @@
package com.ktds.hi.store.infra.gateway.repository;
import com.ktds.hi.store.domain.TagCategory;
import com.ktds.hi.store.infra.gateway.entity.TagEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 태그 JPA 리포지토리 인터페이스
* 태그 데이터의 CRUD 작업을 담당
*
* @author 하이오더 개발팀
* @version 1.0.0
*/
@Repository
public interface TagJpaRepository extends JpaRepository<TagEntity, Long> {
/**
* 활성화된 태그 목록 조회
*/
List<TagEntity> findByIsActiveTrueOrderByTagName();
/**
* 태그명으로 조회
*/
Optional<TagEntity> findByTagNameAndIsActiveTrue(String tagName);
/**
* 카테고리별 태그 조회
*/
List<TagEntity> findByTagCategoryAndIsActiveTrueOrderByTagName(TagCategory category);
/**
* 클릭 기준 상위 태그 조회
*/
@Query("SELECT t FROM TagEntity t WHERE t.isActive = true ORDER BY t.clickCount DESC")
List<TagEntity> findTopClickedTags(PageRequest pageRequest);
/**
* 클릭 기준 상위 5개 태그 조회
*/
@Query("SELECT t FROM TagEntity t WHERE t.isActive = true ORDER BY t.clickCount DESC")
List<TagEntity> findTop5ByOrderByClickCountDesc(PageRequest pageRequest);
}