This commit is contained in:
UNGGU0704 2025-06-17 14:05:21 +09:00
commit 1daa087bda
22 changed files with 567 additions and 37 deletions

View File

@ -21,6 +21,7 @@ public class ActionPlan {
private Long id;
private Long storeId;
private Long userId;
private Long feedbackId;
private String title;
private String description;
private String period;

View File

@ -41,6 +41,7 @@ public class ActionPlanService implements ActionPlanUseCase {
.map(plan -> ActionPlanListResponse.builder()
.id(plan.getId())
.title(plan.getTitle())
.description(plan.getDescription())
.status(plan.getStatus())
.period(plan.getPeriod())
.createdAt(plan.getCreatedAt())

View File

@ -115,7 +115,8 @@ public class AnalyticsService implements AnalyticsUseCase {
// 2. AI 피드백이 없으면 새로 생성
aiFeedback = Optional.of(generateAIFeedback(storeId));
}
// 3. 응답 생성
AiFeedbackDetailResponse response = AiFeedbackDetailResponse.builder()
.feedbackId(aiFeedback.get().getId())
@ -128,6 +129,11 @@ public class AnalyticsService implements AnalyticsUseCase {
.confidenceScore(aiFeedback.get().getConfidenceScore())
.generatedAt(aiFeedback.get().getGeneratedAt())
.build();
//(추가) 실행계획을 조회해서, 이미 생성된 improvementPoints인지 판단
List<String> actionPlanTitleList = actionPlanPort.findActionPlanTitleByFeedbackId(aiFeedback.get().getId());
log.info("실행계획 확인 => {}", actionPlanTitleList.toString());
response.updateImprovementCheck(actionPlanTitleList); //이미 생성된 실행계획 추가.
log.info("AI 피드백 상세 조회 완료: storeId={}", storeId);
return response;
@ -444,9 +450,10 @@ public class AnalyticsService implements AnalyticsUseCase {
// 2. 실행계획 생성 (요청 )
List<String> actionPlans = null;
if (Boolean.TRUE.equals(request.getGenerateActionPlan())) {
actionPlans = aiServicePort.generateActionPlan(aiFeedback);
}
//TODO : 추후에 AI 분석후에 바로 실행계획까지 생성해야 한다면 추가.
// if (Boolean.TRUE.equals(request.getGenerateActionPlan())) {
// actionPlans = aiServicePort.generateActionPlan(aiFeedback);
// }
// 3. 응답 생성
AiAnalysisResponse response = AiAnalysisResponse.builder()
@ -459,7 +466,7 @@ public class AnalyticsService implements AnalyticsUseCase {
.sentimentAnalysis(aiFeedback.getSentimentAnalysis())
.confidenceScore(aiFeedback.getConfidenceScore())
.totalReviewsAnalyzed(getTotalReviewsCount(storeId, request.getDays()))
.actionPlans(actionPlans)
.actionPlans(actionPlans) //TODO : 사용하는 값은 아니지만 의존성을 위해 그대로 , 추후에 변경 필요.
.analyzedAt(aiFeedback.getGeneratedAt())
.build();
@ -474,7 +481,7 @@ public class AnalyticsService implements AnalyticsUseCase {
@Override
@Transactional
public List<String> generateActionPlansFromFeedback(Long feedbackId) {
public List<String> generateActionPlansFromFeedback(ActionPlanCreateRequest request, Long feedbackId) {
log.info("실행계획 생성: feedbackId={}", feedbackId);
try {
@ -487,11 +494,11 @@ public class AnalyticsService implements AnalyticsUseCase {
AiFeedback feedback = aiFeedback.get();
// 2. 기존 AIServicePort.generateActionPlan 메서드 활용
List<String> actionPlans = aiServicePort.generateActionPlan(aiFeedback.get());
List<String> actionPlans = aiServicePort.generateActionPlan(request.getActionPlanSelect(), aiFeedback.get());
// 3. DB에 실행계획 저장
saveGeneratedActionPlansToDatabase(feedback, actionPlans);
saveGeneratedActionPlansToDatabase(request.getActionPlanSelect(), feedback, actionPlans);
log.info("실행계획 생성 완료: feedbackId={}, planCount={}", feedbackId, actionPlans.size());
return actionPlans;
@ -578,7 +585,7 @@ public class AnalyticsService implements AnalyticsUseCase {
* 생성된 실행계획을 데이터베이스에 저장하는 메서드
* AI 피드백 기반으로 생성된 실행계획들을 ActionPlan 테이블에 저장
*/
private void saveGeneratedActionPlansToDatabase(AiFeedback feedback, List<String> actionPlans) {
private void saveGeneratedActionPlansToDatabase(List<String> actionPlanSelect, AiFeedback feedback, List<String> actionPlans) {
if (actionPlans.isEmpty()) {
log.info("저장할 실행계획이 없습니다: storeId={}", feedback.getStoreId());
return;
@ -593,8 +600,9 @@ public class AnalyticsService implements AnalyticsUseCase {
// ActionPlan 도메인 객체 생성 (기존 ActionPlanService의 패턴과 동일하게)
ActionPlan actionPlan = ActionPlan.builder()
.storeId(feedback.getStoreId())
.userId(1L) // AI가 생성한 계획이므로 userId는 null
.title("AI 추천 실행계획 " + (i + 1))
.userId(0L) // AI가 생성한 계획이므로 userId는 0
.feedbackId(feedback.getId())
.title(actionPlanSelect.get(i))
.description(planContent)
.period("1개월") // 기본 실행 기간
.status(PlanStatus.PLANNED)

View File

@ -44,6 +44,6 @@ public interface AnalyticsUseCase {
/**
* AI 피드백 기반 실행계획 생성
*/
List<String> generateActionPlansFromFeedback(Long feedbackId);
List<String> generateActionPlansFromFeedback(ActionPlanCreateRequest request,Long feedbackId);
}

View File

@ -24,5 +24,5 @@ public interface AIServicePort {
/**
* 실행 계획 생성
*/
List<String> generateActionPlan(AiFeedback feedback);
List<String> generateActionPlan(List<String> actionPlanSelect, AiFeedback feedback);
}

View File

@ -30,4 +30,9 @@ public interface ActionPlanPort {
* 실행 계획 삭제
*/
void deleteActionPlan(Long planId);
/**
* 피드백 id로 실행계획 title 조회
*/
List<String> findActionPlanTitleByFeedbackId(Long feedbackId);
}

View File

@ -145,14 +145,17 @@ public class AnalyticsController {
*/
@Operation(summary = "실행계획 생성", description = "AI 피드백을 기반으로 실행계획을 생성합니다.")
@PostMapping("/ai-feedback/{feedbackId}/action-plans")
public ResponseEntity<SuccessResponse<List<String>>> generateActionPlans(
public ResponseEntity<SuccessResponse<Void>> generateActionPlans(
@Parameter(description = "AI 피드백 ID", required = true)
@PathVariable @NotNull Long feedbackId) {
@PathVariable @NotNull Long feedbackId,
@RequestBody ActionPlanCreateRequest request) {
log.info("실행계획 생성 요청: feedbackId={}", feedbackId);
List<String> actionPlans = analyticsUseCase.generateActionPlansFromFeedback(feedbackId);
log.info("실행계획 바디 데이터 => {}", request);
return ResponseEntity.ok(SuccessResponse.of(actionPlans, "실행계획 생성 완료"));
List<String> actionPlans = analyticsUseCase.generateActionPlansFromFeedback(request,feedbackId);
return ResponseEntity.ok(SuccessResponse.of("실행계획 생성 완료"));
}
}

View File

@ -0,0 +1,22 @@
package com.ktds.hi.analytics.infra.dto;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* 실행계획 생성요청
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ActionPlanCreateRequest {
private List<String> actionPlanSelect;
}

View File

@ -20,6 +20,7 @@ public class ActionPlanListResponse {
private Long id;
private String title;
private PlanStatus status;
private String description;
private String period;
private LocalDateTime createdAt;
private LocalDateTime completedAt;

View File

@ -7,6 +7,8 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* AI 피드백 상세 응답 DTO
@ -22,8 +24,23 @@ public class AiFeedbackDetailResponse {
private String summary;
private List<String> positivePoints;
private List<String> improvementPoints;
private List<String> existActionPlan; // improvemnetPoints 중에서 처리 된것.
private List<String> recommendations;
private String sentimentAnalysis;
private Double confidenceScore;
private LocalDateTime generatedAt;
public void updateImprovementCheck(List<String> actionPlanTitle){
Set<String> trimmedTitles = actionPlanTitle.stream()
.map(String::trim)
.collect(Collectors.toSet());
this.existActionPlan =
improvementPoints.stream()
.map(String::trim)
.filter(point -> trimmedTitles.stream()
.anyMatch(title -> title.contains(point)))
.toList();
}
}

View File

@ -0,0 +1,19 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderListResponse {
private Long storeId;
private Integer totalCount;
private List<OrderResponse> orders;
}

View File

@ -0,0 +1,24 @@
package com.ktds.hi.analytics.infra.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderResponse {
private Long id;
private Long storeId;
private Long menuId;
private Integer customerAge;
private String customerGender;
private BigDecimal orderAmount;
private LocalDateTime orderDate;
private LocalDateTime createdAt;
}

View File

@ -125,25 +125,30 @@ public class AIServiceAdapter implements AIServicePort {
}
@Override
public List<String> generateActionPlan(AiFeedback feedback) {
public List<String> generateActionPlan(List<String> actionPlanSelect, AiFeedback feedback) {
log.info("OpenAI 실행 계획 생성 시작");
try {
StringBuffer planFormat = new StringBuffer();
for(int i = 1; i <= actionPlanSelect.size(); i++) {
planFormat.append(i).append(" [구체적인 실행 계획 ").append(i).append("]\n");
}
String prompt = String.format(
"""
다음 AI 피드백을 바탕으로 구체적인 실행 계획 3개를 생성해주세요.
다음 AI 피드백을 바탕으로 구체적인 실행 계획 %s개를 생성해주세요.
계획은 실행 가능하고 구체적이어야 합니다.
요약: %s
개선점: %s
실행계획 내용은 점주가 반영할 있도록 구체적어야 합니다.
실행 계획을 다음 형식으로 작성해주세요:
1. [구체적인 실행 계획 1]
2. [구체적인 실행 계획 2]
3. [구체적인 실행 계획 3]
%s
""",
actionPlanSelect.size(),
feedback.getSummary(),
String.join(", ", feedback.getImprovementPoints())
String.join(", ", actionPlanSelect),
planFormat
);
String result = callOpenAI(prompt);
@ -151,10 +156,9 @@ public class AIServiceAdapter implements AIServicePort {
} catch (Exception e) {
log.error("OpenAI 실행 계획 생성 중 오류 발생", e);
//TODO : 시연을 위해서 우선은 아래과 같이 처리, 추후에는 실행계획이 실패했다면 Runtime계열의 예외를 던지는게 좋을듯.
return Arrays.asList(
"서비스 품질 개선을 위한 직원 교육 실시",
"고객 피드백 수집 체계 구축",
"매장 운영 프로세스 개선"
"실행계획 생성 실패. 재생성 필요"
);
}
}

View File

@ -52,7 +52,12 @@ public class ActionPlanRepositoryAdapter implements ActionPlanPort {
public void deleteActionPlan(Long planId) {
actionPlanJpaRepository.deleteById(planId);
}
@Override
public List<String> findActionPlanTitleByFeedbackId(Long feedbackId) {
return actionPlanJpaRepository.findActionPlanTitleByFeedbackId(feedbackId);
}
/**
* Entity를 Domain으로 변환
*/
@ -79,6 +84,7 @@ public class ActionPlanRepositoryAdapter implements ActionPlanPort {
private ActionPlanEntity toEntity(ActionPlan domain) {
return ActionPlanEntity.builder()
.id(domain.getId())
.feedbackId(domain.getFeedbackId())
.storeId(domain.getStoreId())
.userId(domain.getUserId())
.title(domain.getTitle())

View File

@ -2,16 +2,27 @@ package com.ktds.hi.analytics.infra.gateway;
import com.ktds.hi.analytics.biz.domain.OrderStatistics;
import com.ktds.hi.analytics.biz.usecase.out.OrderDataPort;
import com.ktds.hi.analytics.infra.dto.OrderListResponse;
import com.ktds.hi.analytics.infra.dto.OrderResponse;
import com.ktds.hi.common.dto.SuccessResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 주문 데이터 어댑터 클래스
@ -32,14 +43,32 @@ public class OrderDataAdapter implements OrderDataPort {
log.info("주문 통계 조회: storeId={}, period={} ~ {}", storeId, startDate, endDate);
try {
String url = String.format("%s/api/orders/stores/%d/statistics?startDate=%s&endDate=%s",
storeServiceUrl, storeId, startDate, endDate);
OrderStatistics statistics = restTemplate.getForObject(url, OrderStatistics.class);
if (statistics != null) {
log.info("주문 통계 조회 완료: storeId={}, totalOrders={}",
storeId, statistics.getTotalOrders());
// LocalDate를 LocalDateTime으로 변환 (시작일은 00:00:00, 종료일은 23:59:59)
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.atTime(23, 59, 59);
String url = String.format("%s/api/stores/orders/store/%d/period?startDate=%s&endDate=%s",
storeServiceUrl, storeId, startDateTime, endDateTime);
// ParameterizedTypeReference를 사용하여 제네릭 타입 안전하게 파싱
ParameterizedTypeReference<SuccessResponse<OrderListResponse>> responseType =
new ParameterizedTypeReference<SuccessResponse<OrderListResponse>>() {};
ResponseEntity<SuccessResponse<OrderListResponse>> responseEntity =
restTemplate.exchange(url, HttpMethod.GET, null, responseType);
SuccessResponse<OrderListResponse> successResponse = responseEntity.getBody();
if (successResponse != null && successResponse.isSuccess() && successResponse.getData() != null) {
OrderListResponse orderListResponse = successResponse.getData();
// OrderListResponse를 OrderStatistics로 변환
OrderStatistics statistics = convertToOrderStatistics(orderListResponse);
log.info("주문 통계 조회 완료: storeId={}, totalOrders={}",
storeId, statistics.getTotalOrders());
return statistics;
}
@ -50,6 +79,125 @@ public class OrderDataAdapter implements OrderDataPort {
// 실패 더미 데이터 반환
return createDummyOrderStatistics(storeId);
}
/**
* OrderListResponse를 OrderStatistics로 변환
*/
private OrderStatistics convertToOrderStatistics(OrderListResponse orderListResponse) {
List<OrderResponse> orders = orderListResponse.getOrders();
if (orders == null || orders.isEmpty()) {
return createEmptyOrderStatistics();
}
// 주문
int totalOrders = orders.size();
// 매출 계산
BigDecimal totalRevenue = orders.stream()
.map(OrderResponse::getOrderAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 평균 주문 금액 계산
double averageOrderValue = totalRevenue.doubleValue() / totalOrders;
// 시간대별 주문 분석 (피크 시간 계산)
Map<Integer, Long> hourlyOrderCount = orders.stream()
.collect(Collectors.groupingBy(
order -> order.getOrderDate().getHour(),
Collectors.counting()
));
int peakHour = hourlyOrderCount.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(12); // 기본값 12시
// 연령대별 분포 계산
Map<String, Integer> ageDistribution = calculateCustomerAgeDistribution(orders);
// 인기 메뉴 계산 (메뉴ID별 주문 횟수) - 실제로는 메뉴명을 가져와야 하지만 임시로 메뉴ID 사용
List<String> popularMenus = orders.stream()
.collect(Collectors.groupingBy(
OrderResponse::getMenuId,
Collectors.counting()
))
.entrySet().stream()
.sorted(Map.Entry.<Long, Long>comparingByValue().reversed())
.limit(4)
.map(entry -> "메뉴" + entry.getKey()) // 실제로는 메뉴명으로 변환 필요
.collect(Collectors.toList());
return OrderStatistics.builder()
.totalOrders(totalOrders)
.totalRevenue(totalRevenue.longValue())
.averageOrderValue(averageOrderValue)
.peakHour(peakHour)
.popularMenus(popularMenus)
.customerAgeDistribution(ageDistribution)
.build();
}
/**
* 연령대별 분포 계산
*/
private Map<String, Integer> calculateCustomerAgeDistribution(List<OrderResponse> orders) {
Map<String, Long> ageGroupCount = orders.stream()
.collect(Collectors.groupingBy(
order -> getAgeGroup(order.getCustomerAge()),
Collectors.counting()
));
// Long을 Integer로 변환
Map<String, Integer> result = new HashMap<>();
ageGroupCount.forEach((key, value) -> result.put(key, value.intValue()));
return result;
}
/**
* 연령대별 분포 계산
*/
private Map<String, Integer> calculateAgeDistribution(List<OrderResponse> orders) {
Map<String, Long> ageGroupCount = orders.stream()
.collect(Collectors.groupingBy(
order -> getAgeGroup(order.getCustomerAge()),
Collectors.counting()
));
// Long을 Integer로 변환
Map<String, Integer> result = new HashMap<>();
ageGroupCount.forEach((key, value) -> result.put(key, value.intValue()));
return result;
}
/**
* 나이를 연령대 그룹으로 변환
*/
private String getAgeGroup(Integer age) {
if (age == null) return "미분류";
if (age < 20) return "10대";
if (age < 30) return "20대";
if (age < 40) return "30대";
if (age < 50) return "40대";
if (age < 60) return "50대";
return "60대+";
}
/**
* 주문 통계 생성
*/
private OrderStatistics createEmptyOrderStatistics() {
return OrderStatistics.builder()
.totalOrders(0)
.totalRevenue(0L)
.averageOrderValue(0.0)
.peakHour(12)
.popularMenus(Arrays.asList())
.customerAgeDistribution(new HashMap<>())
.build();
}
@Override
public Integer getCurrentOrderCount(Long storeId) {

View File

@ -49,7 +49,10 @@ public class ActionPlanEntity {
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "feedback_id", nullable = true)
private Long feedbackId;
@Column(name = "title", nullable = false, length = 100)
private String title;

View File

@ -3,10 +3,13 @@ package com.ktds.hi.analytics.infra.gateway.repository;
import com.ktds.hi.analytics.biz.domain.PlanStatus;
import com.ktds.hi.analytics.infra.gateway.entity.ActionPlanEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import io.lettuce.core.dynamic.annotation.Param;
/**
* 실행 계획 JPA 리포지토리 인터페이스
* 실행 계획 데이터의 CRUD 작업을 담당
@ -33,4 +36,10 @@ public interface ActionPlanJpaRepository extends JpaRepository<ActionPlanEntity,
* 매장 ID와 사용자 ID로 실행 계획 목록 조회
*/
List<ActionPlanEntity> findByStoreIdAndUserIdOrderByCreatedAtDesc(Long storeId, Long userId);
/**
* 피드백 id로 실행계획 title 조회
*/
@Query("SELECT a.title FROM ActionPlanEntity a WHERE a.feedbackId = :feedbackId")
List<String> findActionPlanTitleByFeedbackId(@Param("feedbackId")Long feedbackId);
}

BIN
dump.rdb Normal file

Binary file not shown.

View File

@ -0,0 +1,48 @@
// member/src/main/java/com/ktds/hi/member/repository/jpa/TagRepository.java
package com.ktds.hi.member.repository.jpa;
import com.ktds.hi.member.repository.entity.TagEntity;
import com.ktds.hi.member.repository.entity.TagCategory;
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 TagRepository extends JpaRepository<TagEntity, Long> {
/**
* 태그명 목록으로 활성 태그 조회
*/
List<TagEntity> findByTagNameInAndIsActiveTrue(List<String> tagNames);
/**
* 활성 태그 전체 조회 (카테고리, 정렬순서별 정렬)
*/
List<TagEntity> findByIsActiveTrueOrderByTagCategoryAscSortOrderAsc();
/**
* 카테고리별 활성 태그 조회 (정렬순서별 정렬)
*/
List<TagEntity> findByTagCategoryAndIsActiveTrueOrderBySortOrderAsc(TagCategory tagCategory);
/**
* 태그명으로 태그 조회
*/
Optional<TagEntity> findByTagNameAndIsActiveTrue(String tagName);
/**
* 카테고리별 태그 개수 조회
*/
Long countByTagCategoryAndIsActiveTrue(TagCategory tagCategory);
/**
* 활성 태그 존재 여부 확인
*/
boolean existsByTagNameAndIsActiveTrue(String tagName);
}

View File

@ -0,0 +1,116 @@
// member/src/main/java/com/ktds/hi/member/service/PreferenceServiceImpl.java
package com.ktds.hi.member.service;
import com.ktds.hi.member.dto.PreferenceRequest;
import com.ktds.hi.member.dto.TasteTagResponse;
import com.ktds.hi.member.domain.TagType;
import com.ktds.hi.member.repository.entity.PreferenceEntity;
import com.ktds.hi.member.repository.entity.TagEntity;
import com.ktds.hi.member.repository.entity.TagCategory;
import com.ktds.hi.member.repository.jpa.PreferenceRepository;
import com.ktds.hi.member.repository.jpa.TagRepository;
import com.ktds.hi.common.exception.BusinessException;
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.stream.Collectors;
/**
* 취향 관리 서비스 구현체
* 취향 정보 등록/수정 태그 관리 기능을 구현
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class PreferenceServiceImpl implements PreferenceService {
private final PreferenceRepository preferenceRepository;
private final TagRepository tagRepository;
@Override
public void savePreference(Long memberId, PreferenceRequest request) {
// 태그 유효성 검증
List<TagEntity> existingTags = tagRepository.findByTagNameInAndIsActiveTrue(request.getTags());
if (existingTags.size() != request.getTags().size()) {
throw new BusinessException("유효하지 않은 태그가 포함되어 있습니다");
}
// 기존 취향 정보 조회
PreferenceEntity preference = preferenceRepository.findByMemberId(memberId)
.orElse(null);
if (preference != null) {
// 기존 정보 업데이트
preference.updatePreference(request.getTags(), request.getHealthInfo(), request.getSpicyLevel());
} else {
// 새로운 취향 정보 생성
preference = PreferenceEntity.builder()
.memberId(memberId)
.tags(request.getTags())
.healthInfo(request.getHealthInfo())
.spicyLevel(request.getSpicyLevel())
.build();
}
preferenceRepository.save(preference);
log.info("취향 정보 저장 완료: memberId={}, tags={}", memberId, request.getTags());
}
@Override
@Transactional(readOnly = true)
public List<TasteTagResponse> getAvailableTags() {
List<TagEntity> tags = tagRepository.findByIsActiveTrueOrderByTagCategoryAscSortOrderAsc();
return tags.stream()
.map(tag -> TasteTagResponse.builder()
.id(tag.getId())
.tagName(tag.getTagName())
.tagType(convertTagCategoryToTagType(tag.getTagCategory()))
.description(tag.getTagCategory().getDescription())
.build())
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<TasteTagResponse> getTagsByType(TagType tagType) {
TagCategory tagCategory = convertTagTypeToTagCategory(tagType);
List<TagEntity> tags = tagRepository.findByTagCategoryAndIsActiveTrueOrderBySortOrderAsc(tagCategory);
return tags.stream()
.map(tag -> TasteTagResponse.builder()
.id(tag.getId())
.tagName(tag.getTagName())
.tagType(tagType)
.description(tag.getTagCategory().getDescription())
.build())
.collect(Collectors.toList());
}
/**
* TagCategory를 TagType으로 변환 (기존 호환성을 위해)
*/
private TagType convertTagCategoryToTagType(TagCategory tagCategory) {
// TagType enum이 존재한다면 적절한 매핑 로직 구현
// 임시로 기본값 반환
return TagType.TASTE; // 실제 매핑 로직 필요
}
/**
* TagType을 TagCategory로 변환 (기존 호환성을 위해)
*/
private TagCategory convertTagTypeToTagCategory(TagType tagType) {
// TagType에 따른 TagCategory 매핑
switch (tagType) {
case TASTE:
return TagCategory.TASTE;
default:
return TagCategory.TASTE;
}
}
}

View File

@ -0,0 +1,52 @@
package com.ktds.hi.store.infra.gateway.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 매장별 태그 클릭 통계 엔티티
* 어떤 매장에서 어떤 태그가 얼마나 클릭되었는지 저장
*/
@Entity
@Table(name = "store_tag_clicks")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreTagClickEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "tag_id", nullable = false)
private Long tagId;
@Column(name = "click_count")
@Builder.Default
private Long clickCount = 0L;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
/**
* 클릭 증가
*/
public void incrementClickCount() {
this.clickCount = this.clickCount != null ? this.clickCount + 1 : 1L;
this.updatedAt = LocalDateTime.now();
}
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,43 @@
package com.ktds.hi.store.infra.gateway.repository;
import com.ktds.hi.store.infra.gateway.entity.StoreTagClickEntity;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface StoreTagClickJpaRepository extends JpaRepository<StoreTagClickEntity, Long> {
/**
* 특정 매장의 가장 많이 클릭된 태그 5개 조회
*/
@Query("SELECT stc FROM StoreTagClickEntity stc WHERE stc.storeId = :storeId " +
"ORDER BY stc.clickCount DESC")
List<StoreTagClickEntity> findTop5TagsByStoreIdOrderByClickCountDesc(
@Param("storeId") Long storeId, PageRequest pageRequest);
/**
* 전체 매장에서 가장 많이 클릭된 태그 5개 조회
*/
@Query("SELECT stc.tagId, SUM(stc.clickCount) as totalClicks " +
"FROM StoreTagClickEntity stc " +
"GROUP BY stc.tagId " +
"ORDER BY totalClicks DESC")
List<Object[]> findTop5TagsGloballyOrderByClickCount(PageRequest pageRequest);
/**
* 매장별 태그별 클릭 통계 조회
*/
Optional<StoreTagClickEntity> findByStoreIdAndTagId(Long storeId, Long tagId);
/**
* 특정 매장의 모든 태그 클릭 통계 조회
*/
List<StoreTagClickEntity> findByStoreIdOrderByClickCountDesc(Long storeId);
}