Merge branch 'main' of https://github.com/dg04-hi/hi-backend
This commit is contained in:
commit
2681382b59
@ -1,7 +1,9 @@
|
|||||||
package com.ktds.hi.analytics.biz.service;
|
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.Analytics;
|
||||||
import com.ktds.hi.analytics.biz.domain.AiFeedback;
|
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.in.AnalyticsUseCase;
|
||||||
import com.ktds.hi.analytics.biz.usecase.out.*;
|
import com.ktds.hi.analytics.biz.usecase.out.*;
|
||||||
import com.ktds.hi.analytics.infra.dto.*;
|
import com.ktds.hi.analytics.infra.dto.*;
|
||||||
@ -23,7 +25,7 @@ import java.util.Optional;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Transactional(readOnly = true)
|
@Transactional
|
||||||
public class AnalyticsService implements AnalyticsUseCase {
|
public class AnalyticsService implements AnalyticsUseCase {
|
||||||
|
|
||||||
private final AnalyticsPort analyticsPort;
|
private final AnalyticsPort analyticsPort;
|
||||||
@ -32,9 +34,10 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
private final OrderDataPort orderDataPort;
|
private final OrderDataPort orderDataPort;
|
||||||
private final CachePort cachePort;
|
private final CachePort cachePort;
|
||||||
private final EventPort eventPort;
|
private final EventPort eventPort;
|
||||||
|
private final ActionPlanPort actionPlanPort; // 추가된 의존성
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Cacheable(value = "storeAnalytics", key = "#storeId")
|
// @Cacheable(value = "storeAnalytics", key = "#storeId")
|
||||||
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
|
public StoreAnalyticsResponse getStoreAnalytics(Long storeId) {
|
||||||
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
|
log.info("매장 분석 데이터 조회 시작: storeId={}", storeId);
|
||||||
|
|
||||||
@ -43,8 +46,15 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
String cacheKey = "analytics:store:" + storeId;
|
String cacheKey = "analytics:store:" + storeId;
|
||||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||||
if (cachedResult.isPresent()) {
|
if (cachedResult.isPresent()) {
|
||||||
log.info("캐시에서 분석 데이터 반환: storeId={}", storeId);
|
Object cached = cachedResult.get();
|
||||||
return (StoreAnalyticsResponse) 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. 데이터베이스에서 기존 분석 데이터 조회
|
// 2. 데이터베이스에서 기존 분석 데이터 조회
|
||||||
@ -81,11 +91,23 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
// ... 나머지 메서드들은 이전과 동일 ...
|
// ... 나머지 메서드들은 이전과 동일 ...
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Cacheable(value = "aiFeedback", key = "#storeId")
|
// @Cacheable(value = "aiFeedback", key = "#storeId")
|
||||||
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
|
public AiFeedbackDetailResponse getAIFeedbackDetail(Long storeId) {
|
||||||
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
|
log.info("AI 피드백 상세 조회 시작: storeId={}", storeId);
|
||||||
|
|
||||||
try {
|
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 피드백 조회
|
// 1. 기존 AI 피드백 조회
|
||||||
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
|
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(storeId);
|
||||||
|
|
||||||
@ -96,6 +118,7 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
|
|
||||||
// 3. 응답 생성
|
// 3. 응답 생성
|
||||||
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
|
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
|
||||||
|
.feedbackId(aiFeedback.get().getId())
|
||||||
.storeId(storeId)
|
.storeId(storeId)
|
||||||
.summary(aiFeedback.get().getSummary())
|
.summary(aiFeedback.get().getSummary())
|
||||||
.positivePoints(aiFeedback.get().getPositivePoints())
|
.positivePoints(aiFeedback.get().getPositivePoints())
|
||||||
@ -124,11 +147,15 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 캐시 키 생성
|
// 1. 캐시 키 생성
|
||||||
|
// 1. 캐시 키 생성 및 확인
|
||||||
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
|
String cacheKey = String.format("statistics:store:%d:%s:%s", storeId, startDate, endDate);
|
||||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||||
if (cachedResult.isPresent()) {
|
if (cachedResult.isPresent()) {
|
||||||
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
|
Object cached = cachedResult.get();
|
||||||
return (StoreStatisticsResponse) cachedResult.get();
|
if (cached instanceof StoreStatisticsResponse) {
|
||||||
|
log.info("캐시에서 통계 데이터 반환: storeId={}", storeId);
|
||||||
|
return (StoreStatisticsResponse) cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
|
// 2. 주문 통계 데이터 조회 (실제 OrderStatistics 도메인 필드 사용)
|
||||||
@ -168,7 +195,10 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
String cacheKey = "ai_feedback_summary:store:" + storeId;
|
String cacheKey = "ai_feedback_summary:store:" + storeId;
|
||||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||||
if (cachedResult.isPresent()) {
|
if (cachedResult.isPresent()) {
|
||||||
return (AiFeedbackSummaryResponse) cachedResult.get();
|
Object cached = cachedResult.get();
|
||||||
|
if (cached instanceof AiFeedbackSummaryResponse) {
|
||||||
|
return (AiFeedbackSummaryResponse) cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. AI 피드백 조회
|
// 2. AI 피드백 조회
|
||||||
@ -219,7 +249,10 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
String cacheKey = "review_analysis:store:" + storeId;
|
String cacheKey = "review_analysis:store:" + storeId;
|
||||||
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
var cachedResult = cachePort.getAnalyticsCache(cacheKey);
|
||||||
if (cachedResult.isPresent()) {
|
if (cachedResult.isPresent()) {
|
||||||
return (ReviewAnalysisResponse) cachedResult.get();
|
Object cached = cachedResult.get();
|
||||||
|
if (cached instanceof ReviewAnalysisResponse) {
|
||||||
|
return (ReviewAnalysisResponse) cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 최근 리뷰 데이터 조회 (30일)
|
// 2. 최근 리뷰 데이터 조회 (30일)
|
||||||
@ -440,20 +473,26 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public List<String> generateActionPlansFromFeedback(Long feedbackId) {
|
public List<String> generateActionPlansFromFeedback(Long feedbackId) {
|
||||||
log.info("실행계획 생성: feedbackId={}", feedbackId);
|
log.info("실행계획 생성: feedbackId={}", feedbackId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. AI 피드백 조회
|
// 1. AI 피드백 조회
|
||||||
var aiFeedback = analyticsPort.findAIFeedbackByStoreId(feedbackId); // 실제로는 feedbackId로 조회하는 메서드 필요
|
var aiFeedback = analyticsPort.findAIFeedbackById(feedbackId);
|
||||||
|
|
||||||
if (aiFeedback.isEmpty()) {
|
if (aiFeedback.isEmpty()) {
|
||||||
throw new RuntimeException("AI 피드백을 찾을 수 없습니다: " + feedbackId);
|
throw new RuntimeException("AI 피드백을 찾을 수 없습니다: " + feedbackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AiFeedback feedback = aiFeedback.get();
|
||||||
// 2. 기존 AIServicePort.generateActionPlan 메서드 활용
|
// 2. 기존 AIServicePort.generateActionPlan 메서드 활용
|
||||||
List<String> actionPlans = aiServicePort.generateActionPlan(aiFeedback.get());
|
List<String> actionPlans = aiServicePort.generateActionPlan(aiFeedback.get());
|
||||||
|
|
||||||
|
|
||||||
|
// 3. DB에 실행계획 저장
|
||||||
|
saveGeneratedActionPlansToDatabase(feedback, actionPlans);
|
||||||
|
|
||||||
log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size());
|
log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size());
|
||||||
return actionPlans;
|
return actionPlans;
|
||||||
|
|
||||||
@ -475,6 +514,8 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
// 1. 리뷰 데이터 수집
|
// 1. 리뷰 데이터 수집
|
||||||
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days);
|
List<String> reviewData = externalReviewPort.getRecentReviews(storeId, days);
|
||||||
|
|
||||||
|
log.info("review Data check ===> {}", reviewData);
|
||||||
|
|
||||||
if (reviewData.isEmpty()) {
|
if (reviewData.isEmpty()) {
|
||||||
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
|
log.warn("AI 피드백 생성을 위한 리뷰 데이터가 없습니다: storeId={}", storeId);
|
||||||
return createDefaultAIFeedback(storeId);
|
return createDefaultAIFeedback(storeId);
|
||||||
@ -533,4 +574,49 @@ public class AnalyticsService implements AnalyticsUseCase {
|
|||||||
.build();
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,13 @@ public interface AnalyticsPort {
|
|||||||
* 매장 ID로 AI 피드백 조회
|
* 매장 ID로 AI 피드백 조회
|
||||||
*/
|
*/
|
||||||
Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId);
|
Optional<AiFeedback> findAIFeedbackByStoreId(Long storeId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 피드백 ID로 조회 (추가된 메서드)
|
||||||
|
*/
|
||||||
|
Optional<AiFeedback> findAIFeedbackById(Long feedbackId);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 피드백 저장
|
* AI 피드백 저장
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -25,7 +25,7 @@ public class AiAnalysisRequest {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Integer days = 30;
|
private Integer days = 30;
|
||||||
|
|
||||||
@Schema(description = "실행계획 자동 생성 여부", example = "true")
|
@Schema(description = "실행계획 자동 생성 여부", example = "false")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean generateActionPlan = true;
|
private Boolean generateActionPlan = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,8 @@ import java.util.List;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class AiFeedbackDetailResponse {
|
public class AiFeedbackDetailResponse {
|
||||||
|
|
||||||
|
private Long feedbackId;
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
private String summary;
|
private String summary;
|
||||||
private List<String> positivePoints;
|
private List<String> positivePoints;
|
||||||
|
|||||||
@ -8,19 +8,30 @@ import com.azure.ai.textanalytics.models.AnalyzeSentimentResult;
|
|||||||
import com.azure.ai.textanalytics.models.DocumentSentiment;
|
import com.azure.ai.textanalytics.models.DocumentSentiment;
|
||||||
import com.azure.ai.textanalytics.models.TextSentiment;
|
import com.azure.ai.textanalytics.models.TextSentiment;
|
||||||
import com.azure.core.credential.AzureKeyCredential;
|
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.AiFeedback;
|
||||||
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
import com.ktds.hi.analytics.biz.domain.SentimentType;
|
||||||
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
|
import com.ktds.hi.analytics.biz.usecase.out.AIServicePort;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.Data;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 서비스 어댑터 클래스
|
* AI 서비스 어댑터 클래스
|
||||||
@ -29,135 +40,356 @@ import java.util.List;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class AIServiceAdapter implements AIServicePort {
|
public class AIServiceAdapter implements AIServicePort {
|
||||||
|
|
||||||
@Value("${ai.azure.cognitive.endpoint}")
|
|
||||||
private String cognitiveEndpoint;
|
@Value("${ai-api.openai.base-url:https://api.openai.com/v1}")
|
||||||
|
private String openaiBaseUrl;
|
||||||
@Value("${ai.azure.cognitive.key}")
|
|
||||||
private String cognitiveKey;
|
@Value("${ai-api.openai.api-key}")
|
||||||
|
|
||||||
@Value("${ai.openai.api-key}")
|
|
||||||
private String openaiApiKey;
|
private String openaiApiKey;
|
||||||
|
|
||||||
|
@Value("${ai-api.openai.model:gpt-4o-mini}")
|
||||||
|
private String openaiModel;
|
||||||
|
|
||||||
private TextAnalyticsClient textAnalyticsClient;
|
private TextAnalyticsClient textAnalyticsClient;
|
||||||
|
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void initializeClients() {
|
public void initializeClients() {
|
||||||
// Azure Cognitive Services 클라이언트 초기화
|
// Azure Cognitive Services 클라이언트 초기화
|
||||||
textAnalyticsClient = new TextAnalyticsClientBuilder()
|
// textAnalyticsClient = new TextAnalyticsClientBuilder()
|
||||||
.credential(new AzureKeyCredential(cognitiveKey))
|
// .credential(new AzureKeyCredential(cognitiveKey))
|
||||||
.endpoint(cognitiveEndpoint)
|
// .endpoint(cognitiveEndpoint)
|
||||||
.buildClient();
|
// .buildClient();
|
||||||
|
//
|
||||||
log.info("AI 서비스 클라이언트 초기화 완료");
|
// 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
|
@Override
|
||||||
public AiFeedback generateFeedback(List<String> reviewData) {
|
public AiFeedback generateFeedback(List<String> reviewData) {
|
||||||
log.info("AI 피드백 생성 시작: 리뷰 수={}", reviewData.size());
|
|
||||||
|
log.info("OpenAI 피드백 생성 시작: 리뷰 수={}", reviewData.size());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (reviewData.isEmpty()) {
|
if (reviewData.isEmpty()) {
|
||||||
return createEmptyFeedback();
|
return createEmptyFeedback();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 감정 분석 수행
|
// OpenAI API 호출하여 전체 리뷰 분석
|
||||||
List<SentimentType> sentiments = reviewData.stream()
|
String analysisResult = callOpenAIForAnalysis(reviewData);
|
||||||
.map(this::analyzeSentiment)
|
|
||||||
.toList();
|
// 결과 파싱 및 AiFeedback 객체 생성
|
||||||
|
return parseAnalysisResult(analysisResult, reviewData.size());
|
||||||
// 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;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("AI 피드백 생성 중 오류 발생", e);
|
log.error("OpenAI 피드백 생성 중 오류 발생", e);
|
||||||
throw new RuntimeException("AI 피드백 생성에 실패했습니다.", e);
|
return createFallbackFeedback(reviewData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SentimentType analyzeSentiment(String content) {
|
public SentimentType analyzeSentiment(String content) {
|
||||||
try {
|
try {
|
||||||
DocumentSentiment documentSentiment = textAnalyticsClient.analyzeSentiment(content);
|
String prompt = String.format(
|
||||||
TextSentiment sentiment = documentSentiment.getSentiment();
|
"다음 리뷰의 감정을 분석해주세요. POSITIVE, NEGATIVE, NEUTRAL 중 하나로만 답변해주세요.\n\n리뷰: %s",
|
||||||
|
content
|
||||||
|
);
|
||||||
|
|
||||||
if (sentiment == TextSentiment.POSITIVE) {
|
String result = callOpenAI(prompt);
|
||||||
|
|
||||||
|
if (result.toUpperCase().contains("POSITIVE")) {
|
||||||
return SentimentType.POSITIVE;
|
return SentimentType.POSITIVE;
|
||||||
} else if (sentiment == TextSentiment.NEGATIVE) {
|
} else if (result.toUpperCase().contains("NEGATIVE")) {
|
||||||
return SentimentType.NEGATIVE;
|
return SentimentType.NEGATIVE;
|
||||||
} else if (sentiment == TextSentiment.NEUTRAL) {
|
|
||||||
return SentimentType.NEUTRAL;
|
|
||||||
} else if (sentiment == TextSentiment.MIXED) {
|
|
||||||
return SentimentType.NEUTRAL; // MIXED는 NEUTRAL로 처리
|
|
||||||
} else {
|
} else {
|
||||||
return SentimentType.NEUTRAL;
|
return SentimentType.NEUTRAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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;
|
return SentimentType.NEUTRAL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> generateActionPlan(AiFeedback feedback) {
|
public List<String> generateActionPlan(AiFeedback feedback) {
|
||||||
log.info("실행 계획 생성 시작");
|
log.info("OpenAI 실행 계획 생성 시작");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 개선점을 기반으로 실행 계획 생성
|
String prompt = String.format(
|
||||||
List<String> actionPlans = feedback.getImprovementPoints().stream()
|
"""
|
||||||
.map(this::convertToActionPlan)
|
다음 AI 피드백을 바탕으로 구체적인 실행 계획 3개를 생성해주세요.
|
||||||
.toList();
|
각 계획은 실행 가능하고 구체적이어야 합니다.
|
||||||
|
|
||||||
log.info("실행 계획 생성 완료: 계획 수={}", actionPlans.size());
|
요약: %s
|
||||||
return actionPlans;
|
개선점: %s
|
||||||
|
|
||||||
|
실행 계획을 다음 형식으로 작성해주세요:
|
||||||
|
1. [구체적인 실행 계획 1]
|
||||||
|
2. [구체적인 실행 계획 2]
|
||||||
|
3. [구체적인 실행 계획 3]
|
||||||
|
""",
|
||||||
|
feedback.getSummary(),
|
||||||
|
String.join(", ", feedback.getImprovementPoints())
|
||||||
|
);
|
||||||
|
|
||||||
|
String result = callOpenAI(prompt);
|
||||||
|
return parseActionPlans(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("실행 계획 생성 중 오류 발생", e);
|
log.error("OpenAI 실행 계획 생성 중 오류 발생", e);
|
||||||
return Arrays.asList("서비스 품질 개선을 위한 직원 교육 실시", "고객 피드백 수집 체계 구축");
|
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() {
|
private AiFeedback createEmptyFeedback() {
|
||||||
return AiFeedback.builder()
|
return AiFeedback.builder()
|
||||||
.summary("분석할 리뷰 데이터가 없습니다.")
|
.summary("분석할 리뷰 데이터가 없습니다.")
|
||||||
.positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가"))
|
.positivePoints(Arrays.asList("리뷰 데이터 부족으로 분석 불가"))
|
||||||
.improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요"))
|
.improvementPoints(Arrays.asList("더 많은 고객 리뷰 수집 필요"))
|
||||||
.recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행"))
|
.recommendations(Arrays.asList("고객들에게 리뷰 작성을 유도하는 이벤트 진행"))
|
||||||
.sentimentAnalysis("데이터 부족")
|
.sentimentAnalysis("데이터 부족")
|
||||||
.confidenceScore(0.0)
|
.confidenceScore(0.0)
|
||||||
.generatedAt(LocalDateTime.now())
|
.generatedAt(LocalDateTime.now())
|
||||||
.build();
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요약 생성
|
* 요약 생성
|
||||||
|
|||||||
@ -55,6 +55,12 @@ public class AnalyticsRepositoryAdapter implements AnalyticsPort {
|
|||||||
AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity);
|
AiFeedbackEntity saved = aiFeedbackJpaRepository.save(entity);
|
||||||
return toAiFeedbackDomain(saved);
|
return toAiFeedbackDomain(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AiFeedback> findAIFeedbackById(Long feedbackId) {
|
||||||
|
return aiFeedbackJpaRepository.findById(feedbackId)
|
||||||
|
.map(this::toAiFeedbackDomain);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics Entity를 Domain으로 변환
|
* Analytics Entity를 Domain으로 변환
|
||||||
|
|||||||
@ -1,14 +1,27 @@
|
|||||||
package com.ktds.hi.analytics.infra.gateway;
|
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 com.ktds.hi.analytics.biz.usecase.out.ExternalReviewPort;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.client.RestTemplate;
|
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.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 리뷰 서비스 어댑터 클래스
|
* 외부 리뷰 서비스 어댑터 클래스
|
||||||
@ -30,11 +43,20 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
|
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/content";
|
||||||
String[] reviewArray = restTemplate.getForObject(url, String[].class);
|
// ReviewListResponse 배열로 직접 받기 (Review 서비스가 List<ReviewListResponse> 반환)
|
||||||
|
ReviewListResponse[] reviewArray = restTemplate.getForObject(url, ReviewListResponse[].class);
|
||||||
List<String> reviews = reviewArray != null ? Arrays.asList(reviewArray) : List.of();
|
|
||||||
|
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());
|
log.info("리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
|
||||||
|
|
||||||
return reviews;
|
return reviews;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -49,13 +71,30 @@ public class ExternalReviewAdapter implements ExternalReviewPort {
|
|||||||
log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
|
log.info("최근 리뷰 데이터 조회: storeId={}, days={}", storeId, days);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days;
|
// String url = reviewServiceUrl + "/api/reviews/stores/" + storeId + "/recent?days=" + days;
|
||||||
String[] reviewArray = restTemplate.getForObject(url, String[].class);
|
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();
|
return recentReviews;
|
||||||
log.info("최근 리뷰 데이터 조회 완료: storeId={}, count={}", storeId, reviews.size());
|
|
||||||
|
|
||||||
return reviews;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("최근 리뷰 데이터 조회 실패: storeId={}", storeId, 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,13 @@ public interface AiFeedbackJpaRepository extends JpaRepository<AiFeedbackEntity,
|
|||||||
* 매장 ID로 AI 피드백 조회 (최신순)
|
* 매장 ID로 AI 피드백 조회 (최신순)
|
||||||
*/
|
*/
|
||||||
Optional<AiFeedbackEntity> findByStoreId(Long storeId);
|
Optional<AiFeedbackEntity> findByStoreId(Long storeId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 ID로 최신 AI 피드백 조회
|
* 매장 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);
|
Optional<AiFeedbackEntity> findLatestByStoreId(@Param("storeId") Long storeId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 기간 이후 생성된 AI 피드백 조회
|
* 특정 기간 이후 생성된 AI 피드백 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -39,7 +39,7 @@ ai-api:
|
|||||||
claude:
|
claude:
|
||||||
api-key: ${CLAUDE_API_KEY:}
|
api-key: ${CLAUDE_API_KEY:}
|
||||||
base-url: https://api.anthropic.com
|
base-url: https://api.anthropic.com
|
||||||
model: claude-3-sonnet-20240229
|
model: claude-sonnet-4-20250514
|
||||||
|
|
||||||
#external-api:
|
#external-api:
|
||||||
# openai:
|
# openai:
|
||||||
|
|||||||
@ -20,7 +20,7 @@ spring:
|
|||||||
# Redis 설정
|
# Redis 설정
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:localhost}
|
host: ${REDIS_HOST:localhost} //로컬
|
||||||
port: ${REDIS_PORT:6379}
|
port: ${REDIS_PORT:6379}
|
||||||
password: ${REDIS_PASSWORD:}
|
password: ${REDIS_PASSWORD:}
|
||||||
timeout: 2000ms
|
timeout: 2000ms
|
||||||
|
|||||||
@ -1,23 +1,48 @@
|
|||||||
package com.ktds.hi.member.domain;
|
package com.ktds.hi.member.domain;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 태그 유형 열거형
|
* 태그 유형 열거형
|
||||||
* 취향 태그의 카테고리를 정의
|
* 취향 태그의 카테고리를 정의
|
||||||
*/
|
*/
|
||||||
public enum TagType {
|
public enum TagType {
|
||||||
CUISINE("음식 종류"),
|
TASTE("맛"),
|
||||||
FLAVOR("맛"),
|
|
||||||
DIETARY("식이 제한"),
|
|
||||||
ATMOSPHERE("분위기"),
|
ATMOSPHERE("분위기"),
|
||||||
PRICE("가격대");
|
ALLERGY("알러지"),
|
||||||
|
SERVICE("서비스"),
|
||||||
|
PRICE_RANGE("가격대"),
|
||||||
|
CUISINE_TYPE("음식 종류"),
|
||||||
|
HEALTH_INFO("건강 정보");
|
||||||
|
|
||||||
private final String description;
|
private final String description;
|
||||||
|
|
||||||
TagType(String description) {
|
TagType(String description) {
|
||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
package com.ktds.hi.member.domain;
|
package com.ktds.hi.member.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Table;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@ -13,11 +14,12 @@ import lombok.NoArgsConstructor;
|
|||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Table(name = "taste_tag")
|
||||||
public class TasteTag {
|
public class TasteTag {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
private String tagName;
|
private String tagName;
|
||||||
private TagType tagType;
|
private TagType tagType; //카테고리
|
||||||
private String description;
|
private String description; //매운맛, 짠맛
|
||||||
private Boolean isActive;
|
private Boolean isActive;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import lombok.NoArgsConstructor;
|
|||||||
* 취향 태그 엔티티 클래스
|
* 취향 태그 엔티티 클래스
|
||||||
* 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티
|
* 데이터베이스 taste_tags 테이블과 매핑되는 JPA 엔티티
|
||||||
*/
|
*/
|
||||||
|
// TasteTagEntity.java
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "taste_tags")
|
@Table(name = "taste_tags")
|
||||||
@Getter
|
@Getter
|
||||||
@ -18,22 +19,32 @@ import lombok.NoArgsConstructor;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class TasteTagEntity {
|
public class TasteTagEntity {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(name = "tag_name", unique = true, nullable = false, length = 50)
|
@Column(name = "tag_name", unique = true, nullable = false, length = 50)
|
||||||
private String tagName;
|
private String tagName; // 매운맛, 단맛, 짠맛 등
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Column(name = "tag_color", length = 7)
|
||||||
@Column(name = "tag_type", nullable = false)
|
private String tagColor; // #FF5722
|
||||||
private TagType tagType;
|
|
||||||
|
@Column(name = "sort_order")
|
||||||
@Column(length = 200)
|
private Integer sortOrder;
|
||||||
private String description;
|
|
||||||
|
|
||||||
@Column(name = "is_active")
|
@Column(name = "is_active")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean isActive = true;
|
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; // 추가된 필드
|
||||||
|
}
|
||||||
@ -1,31 +1,30 @@
|
|||||||
|
/*
|
||||||
|
*/
|
||||||
package com.ktds.hi.member.repository.jpa;
|
package com.ktds.hi.member.repository.jpa;
|
||||||
|
|
||||||
import com.ktds.hi.member.domain.TagType;
|
import com.ktds.hi.member.domain.TagType;
|
||||||
|
import com.ktds.hi.member.repository.entity.TagCategory;
|
||||||
import com.ktds.hi.member.repository.entity.TasteTagEntity;
|
import com.ktds.hi.member.repository.entity.TasteTagEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
|
||||||
* 취향 태그 JPA 리포지토리 인터페이스
|
|
||||||
* 취향 태그 데이터의 CRUD 작업을 담당
|
|
||||||
*/
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface TasteTagRepository extends JpaRepository<TasteTagEntity, Long> {
|
public interface TasteTagRepository extends JpaRepository<TasteTagEntity, Long> {
|
||||||
|
|
||||||
/**
|
|
||||||
* 활성화된 태그 목록 조회
|
|
||||||
*/
|
|
||||||
List<TasteTagEntity> findByIsActiveTrue();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 태그 유형별 태그 목록 조회
|
|
||||||
*/
|
|
||||||
List<TasteTagEntity> findByTagTypeAndIsActiveTrue(TagType tagType);
|
List<TasteTagEntity> findByTagTypeAndIsActiveTrue(TagType tagType);
|
||||||
|
|
||||||
/**
|
List<TasteTagEntity> findByIsActiveTrue();
|
||||||
* 태그명으로 태그 조회
|
|
||||||
*/
|
|
||||||
List<TasteTagEntity> findByTagNameIn(List<String> tagNames);
|
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);
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -14,6 +14,16 @@ import java.util.Optional;
|
|||||||
*/
|
*/
|
||||||
public interface StoreRepositoryPort {
|
public interface StoreRepositoryPort {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 태그로 매장 검색 (OR 조건)
|
||||||
|
*/
|
||||||
|
List<Store> findStoresByTagNames(List<String> tagNames);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 태그를 포함하는 매장 검색 (AND 조건)
|
||||||
|
*/
|
||||||
|
List<Store> findStoresByAllTagNames(List<String> tagNames);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 점주 ID로 매장 목록 조회
|
* 점주 ID로 매장 목록 조회
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
46
store/src/main/java/com/ktds/hi/store/domain/Tag.java
Normal file
46
store/src/main/java/com/ktds/hi/store/domain/Tag.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -38,6 +38,22 @@ public class StoreRepositoryAdapter implements StoreRepositoryPort {
|
|||||||
.collect(Collectors.toList());
|
.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
|
@Override
|
||||||
public Optional<Store> findStoreById(Long storeId) {
|
public Optional<Store> findStoreById(Long storeId) {
|
||||||
return storeJpaRepository.findById(storeId)
|
return storeJpaRepository.findById(storeId)
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,6 +21,9 @@ import java.util.Optional;
|
|||||||
@Repository
|
@Repository
|
||||||
public interface StoreJpaRepository extends JpaRepository<StoreEntity, Long> {
|
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로 매장 목록 조회
|
* 점주 ID로 매장 목록 조회
|
||||||
*/
|
*/
|
||||||
@ -49,9 +52,18 @@ public interface StoreJpaRepository extends JpaRepository<StoreEntity, Long> {
|
|||||||
/**
|
/**
|
||||||
* 평점 기준 내림차순으로 매장 조회
|
* 평점 기준 내림차순으로 매장 조회
|
||||||
*/
|
*/
|
||||||
@Query("SELECT s FROM StoreEntity s ORDER BY s.rating DESC")
|
@Query(value = "SELECT DISTINCT s.* FROM stores s " +
|
||||||
Page<StoreEntity> findAllByOrderByRatingDesc(Pageable pageable);
|
"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);
|
||||||
/**
|
/**
|
||||||
* 점주별 매장 수 조회
|
* 점주별 매장 수 조회
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user