mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2025-12-06 07:06:24 +00:00
feat: marketing tip
This commit is contained in:
parent
0b4c543b19
commit
b5d896aadf
@ -6,21 +6,21 @@ import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
|
|||||||
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
||||||
import com.won.smarketing.recommend.domain.model.StoreData;
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
||||||
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
|
||||||
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
|
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
/**
|
import java.time.LocalDateTime;
|
||||||
* 마케팅 팁 서비스 구현체
|
import java.util.Optional;
|
||||||
*/
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -32,70 +32,134 @@ public class MarketingTipService implements MarketingTipUseCase {
|
|||||||
private final AiTipGenerator aiTipGenerator;
|
private final AiTipGenerator aiTipGenerator;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
|
public MarketingTipResponse provideMarketingTip() {
|
||||||
log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId());
|
String userId = getCurrentUserId();
|
||||||
|
log.info("마케팅 팁 제공: userId={}", userId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 매장 정보 조회
|
// 1. 사용자의 매장 정보 조회
|
||||||
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
|
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
|
||||||
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
|
|
||||||
|
|
||||||
// 2. Python AI 서비스로 팁 생성 (매장 정보 + 추가 요청사항 전달)
|
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
|
||||||
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement());
|
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeData.getStoreId());
|
||||||
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
|
|
||||||
|
|
||||||
// 3. 도메인 객체 생성 및 저장
|
if (recentTip.isPresent()) {
|
||||||
MarketingTip marketingTip = MarketingTip.builder()
|
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
|
||||||
.storeId(request.getStoreId())
|
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
|
||||||
.tipContent(aiGeneratedTip)
|
return convertToResponse(recentTip.get(), storeData, true);
|
||||||
.storeData(storeData)
|
}
|
||||||
.build();
|
|
||||||
|
|
||||||
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
|
// 3. 1시간 이내 팁이 없으면 새로 생성
|
||||||
log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
|
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeData.getStoreId());
|
||||||
|
MarketingTip newTip = createNewMarketingTip(storeData);
|
||||||
return convertToResponse(savedTip);
|
return convertToResponse(newTip, storeData, false);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
|
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
|
||||||
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
|
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
@Transactional(readOnly = true)
|
* DB에서 1시간 이내 생성된 마케팅 팁 조회
|
||||||
@Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize")
|
*/
|
||||||
public Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable) {
|
private Optional<MarketingTip> findRecentMarketingTip(Long storeId) {
|
||||||
log.info("마케팅 팁 이력 조회: storeId={}", storeId);
|
log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId);
|
||||||
|
|
||||||
Page<MarketingTip> tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
|
// 최근 생성된 팁 1개 조회
|
||||||
|
Pageable pageable = PageRequest.of(0, 1);
|
||||||
|
Page<MarketingTip> recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
|
||||||
|
|
||||||
return tips.map(this::convertToResponse);
|
if (recentTips.isEmpty()) {
|
||||||
|
log.debug("매장의 마케팅 팁이 존재하지 않음: storeId={}", storeId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
MarketingTip mostRecentTip = recentTips.getContent().get(0);
|
||||||
|
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
|
||||||
|
|
||||||
|
// 1시간 이내에 생성된 팁인지 확인
|
||||||
|
if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) {
|
||||||
|
log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}",
|
||||||
|
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
|
||||||
|
return Optional.of(mostRecentTip);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}",
|
||||||
|
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
|
||||||
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
@Transactional(readOnly = true)
|
* 새로운 마케팅 팁 생성
|
||||||
public MarketingTipResponse getMarketingTip(Long tipId) {
|
*/
|
||||||
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
|
private MarketingTip createNewMarketingTip(StoreData storeData) {
|
||||||
|
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeData.getStoreName());
|
||||||
|
|
||||||
MarketingTip marketingTip = marketingTipRepository.findById(tipId)
|
// AI 서비스로 팁 생성
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR));
|
String aiGeneratedTip = aiTipGenerator.generateTip(storeData);
|
||||||
|
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
|
||||||
|
|
||||||
return convertToResponse(marketingTip);
|
// 도메인 객체 생성 및 저장
|
||||||
|
MarketingTip marketingTip = MarketingTip.builder()
|
||||||
|
.storeId(storeData.getStoreId())
|
||||||
|
.tipContent(aiGeneratedTip)
|
||||||
|
.storeData(storeData)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
|
||||||
|
log.info("새로운 마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
|
||||||
|
log.info("새로운 마케팅 팁 저장 완료: savedTip.getTipContent()={}", savedTip.getTipContent());
|
||||||
|
|
||||||
|
return savedTip;
|
||||||
}
|
}
|
||||||
|
|
||||||
private MarketingTipResponse convertToResponse(MarketingTip marketingTip) {
|
/**
|
||||||
|
* 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함)
|
||||||
|
*/
|
||||||
|
private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) {
|
||||||
|
String tipSummary = generateTipSummary(marketingTip.getTipContent());
|
||||||
|
|
||||||
return MarketingTipResponse.builder()
|
return MarketingTipResponse.builder()
|
||||||
.tipId(marketingTip.getId().getValue())
|
.tipId(marketingTip.getId().getValue())
|
||||||
.storeId(marketingTip.getStoreId())
|
.tipSummary(tipSummary)
|
||||||
.storeName(marketingTip.getStoreData().getStoreName())
|
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
|
||||||
.tipContent(marketingTip.getTipContent())
|
|
||||||
.storeInfo(MarketingTipResponse.StoreInfo.builder()
|
.storeInfo(MarketingTipResponse.StoreInfo.builder()
|
||||||
.storeName(marketingTip.getStoreData().getStoreName())
|
.storeName(storeData.getStoreName())
|
||||||
.businessType(marketingTip.getStoreData().getBusinessType())
|
.businessType(storeData.getBusinessType())
|
||||||
.location(marketingTip.getStoreData().getLocation())
|
.location(storeData.getLocation())
|
||||||
.build())
|
.build())
|
||||||
.createdAt(marketingTip.getCreatedAt())
|
.createdAt(marketingTip.getCreatedAt())
|
||||||
|
.updatedAt(marketingTip.getUpdatedAt())
|
||||||
|
.isRecentlyCreated(isRecentlyCreated)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장)
|
||||||
|
*/
|
||||||
|
private String generateTipSummary(String fullContent) {
|
||||||
|
if (fullContent == null || fullContent.trim().isEmpty()) {
|
||||||
|
return "마케팅 팁이 생성되었습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 문장으로 요약 (마침표 기준)
|
||||||
|
String[] sentences = fullContent.split("[.!?]");
|
||||||
|
String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent;
|
||||||
|
|
||||||
|
// 50자 제한
|
||||||
|
if (firstSentence.length() > 50) {
|
||||||
|
return firstSentence.substring(0, 47) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstSentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인된 사용자 ID 조회
|
||||||
|
*/
|
||||||
|
private String getCurrentUserId() {
|
||||||
|
return SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,27 +1,12 @@
|
|||||||
package com.won.smarketing.recommend.application.usecase;
|
package com.won.smarketing.recommend.application.usecase;
|
||||||
|
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
|
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마케팅 팁 유즈케이스 인터페이스
|
|
||||||
*/
|
|
||||||
public interface MarketingTipUseCase {
|
public interface MarketingTipUseCase {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 마케팅 팁 생성
|
* 마케팅 팁 제공
|
||||||
|
* 1시간 이내 팁이 있으면 기존 것 사용, 없으면 새로 생성
|
||||||
*/
|
*/
|
||||||
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
|
MarketingTipResponse provideMarketingTip();
|
||||||
|
|
||||||
/**
|
|
||||||
* 마케팅 팁 이력 조회
|
|
||||||
*/
|
|
||||||
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 마케팅 팁 상세 조회
|
|
||||||
*/
|
|
||||||
MarketingTipResponse getMarketingTip(Long tipId);
|
|
||||||
}
|
}
|
||||||
@ -4,6 +4,7 @@ import lombok.AllArgsConstructor;
|
|||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.cglib.core.Local;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@ -18,9 +19,11 @@ public class MarketingTip {
|
|||||||
|
|
||||||
private TipId id;
|
private TipId id;
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
|
private String tipSummary;
|
||||||
private String tipContent;
|
private String tipContent;
|
||||||
private StoreData storeData;
|
private StoreData storeData;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) {
|
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) {
|
||||||
return MarketingTip.builder()
|
return MarketingTip.builder()
|
||||||
|
|||||||
@ -13,7 +13,10 @@ import lombok.NoArgsConstructor;
|
|||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class StoreData {
|
public class StoreData {
|
||||||
|
private Long storeId;
|
||||||
private String storeName;
|
private String storeName;
|
||||||
private String businessType;
|
private String businessType;
|
||||||
private String location;
|
private String location;
|
||||||
|
private String description;
|
||||||
|
private Integer seatCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,7 @@ public interface AiTipGenerator {
|
|||||||
* Python AI 서비스를 통한 마케팅 팁 생성
|
* Python AI 서비스를 통한 마케팅 팁 생성
|
||||||
*
|
*
|
||||||
* @param storeData 매장 정보
|
* @param storeData 매장 정보
|
||||||
* @param additionalRequirement 추가 요청사항
|
|
||||||
* @return AI가 생성한 마케팅 팁
|
* @return AI가 생성한 마케팅 팁
|
||||||
*/
|
*/
|
||||||
String generateTip(StoreData storeData, String additionalRequirement);
|
String generateTip(StoreData storeData);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,5 +7,11 @@ import com.won.smarketing.recommend.domain.model.StoreData;
|
|||||||
*/
|
*/
|
||||||
public interface StoreDataProvider {
|
public interface StoreDataProvider {
|
||||||
|
|
||||||
StoreData getStoreData(Long storeId);
|
/**
|
||||||
|
* 사용자 ID로 매장 정보 조회
|
||||||
|
*
|
||||||
|
* @param userId 사용자 ID
|
||||||
|
* @return 매장 정보
|
||||||
|
*/
|
||||||
|
StoreData getStoreDataByUserId(String userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.won.smarketing.recommend.infrastructure.external;
|
|||||||
|
|
||||||
import com.won.smarketing.recommend.domain.model.StoreData;
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
||||||
|
import lombok.Getter;
|
||||||
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;
|
||||||
@ -9,6 +10,7 @@ import org.springframework.stereotype.Service; // 이 어노테이션이 누락
|
|||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -31,36 +33,25 @@ public class PythonAiTipGenerator implements AiTipGenerator {
|
|||||||
private int timeout;
|
private int timeout;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateTip(StoreData storeData, String additionalRequirement) {
|
public String generateTip(StoreData storeData) {
|
||||||
try {
|
try {
|
||||||
log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName());
|
log.debug("Python AI 서비스 직접 호출: store={}", storeData.getStoreName());
|
||||||
|
return callPythonAiService(storeData);
|
||||||
// Python AI 서비스 사용 가능 여부 확인
|
|
||||||
if (isPythonServiceAvailable()) {
|
|
||||||
return callPythonAiService(storeData, additionalRequirement);
|
|
||||||
} else {
|
|
||||||
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
|
|
||||||
return createFallbackTip(storeData, additionalRequirement);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
|
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
|
||||||
return createFallbackTip(storeData, additionalRequirement);
|
return createFallbackTip(storeData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPythonServiceAvailable() {
|
private String callPythonAiService(StoreData storeData) {
|
||||||
return !pythonAiServiceApiKey.equals("dummy-key");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String callPythonAiService(StoreData storeData, String additionalRequirement) {
|
|
||||||
try {
|
try {
|
||||||
// Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달)
|
// Python AI 서비스로 전송할 데이터
|
||||||
Map<String, Object> requestData = Map.of(
|
Map<String, Object> requestData = Map.of(
|
||||||
"store_name", storeData.getStoreName(),
|
"store_name", storeData.getStoreName(),
|
||||||
"business_type", storeData.getBusinessType(),
|
"business_type", storeData.getBusinessType(),
|
||||||
"location", storeData.getLocation(),
|
"location", storeData.getLocation(),
|
||||||
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
|
"seat_count", storeData.getSeatCount()
|
||||||
);
|
);
|
||||||
|
|
||||||
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
|
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
|
||||||
@ -84,23 +75,17 @@ public class PythonAiTipGenerator implements AiTipGenerator {
|
|||||||
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
|
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return createFallbackTip(storeData, additionalRequirement);
|
return createFallbackTip(storeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
|
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
|
||||||
*/
|
*/
|
||||||
private String createFallbackTip(StoreData storeData, String additionalRequirement) {
|
private String createFallbackTip(StoreData storeData) {
|
||||||
String businessType = storeData.getBusinessType();
|
String businessType = storeData.getBusinessType();
|
||||||
String storeName = storeData.getStoreName();
|
String storeName = storeData.getStoreName();
|
||||||
String location = storeData.getLocation();
|
String location = storeData.getLocation();
|
||||||
|
|
||||||
// 추가 요청사항이 있는 경우 우선 반영
|
|
||||||
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
|
|
||||||
return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!",
|
|
||||||
storeName, additionalRequirement);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 업종별 기본 팁 생성
|
// 업종별 기본 팁 생성
|
||||||
if (businessType.contains("카페")) {
|
if (businessType.contains("카페")) {
|
||||||
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
|
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
|
||||||
@ -123,16 +108,13 @@ public class PythonAiTipGenerator implements AiTipGenerator {
|
|||||||
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
|
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
private static class PythonAiResponse {
|
private static class PythonAiResponse {
|
||||||
private String tip;
|
private String tip;
|
||||||
private String status;
|
private String status;
|
||||||
private String message;
|
private String message;
|
||||||
|
private LocalDateTime generatedTip;
|
||||||
public String getTip() { return tip; }
|
private String businessType;
|
||||||
public void setTip(String tip) { this.tip = tip; }
|
private String aiModel;
|
||||||
public String getStatus() { return status; }
|
|
||||||
public void setStatus(String status) { this.status = status; }
|
|
||||||
public String getMessage() { return message; }
|
|
||||||
public void setMessage(String message) { this.message = message; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,11 +4,16 @@ import com.won.smarketing.common.exception.BusinessException;
|
|||||||
import com.won.smarketing.common.exception.ErrorCode;
|
import com.won.smarketing.common.exception.ErrorCode;
|
||||||
import com.won.smarketing.recommend.domain.model.StoreData;
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.Getter;
|
||||||
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.cache.annotation.Cacheable;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
@ -30,46 +35,51 @@ public class StoreApiDataProvider implements StoreDataProvider {
|
|||||||
@Value("${external.store-service.timeout}")
|
@Value("${external.store-service.timeout}")
|
||||||
private int timeout;
|
private int timeout;
|
||||||
|
|
||||||
@Override
|
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||||
@Cacheable(value = "storeData", key = "#storeId")
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
public StoreData getStoreData(Long storeId) {
|
|
||||||
try {
|
|
||||||
log.debug("매장 정보 조회 시도: storeId={}", storeId);
|
|
||||||
|
|
||||||
// 외부 서비스 연결 시도, 실패 시 Mock 데이터 반환
|
/**
|
||||||
if (isStoreServiceAvailable()) {
|
* 사용자 ID로 매장 정보 조회
|
||||||
return callStoreService(storeId);
|
*
|
||||||
} else {
|
* @param userId 사용자 ID
|
||||||
log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId);
|
* @return 매장 정보
|
||||||
return createMockStoreData(storeId);
|
*/
|
||||||
}
|
@Override
|
||||||
|
public StoreData getStoreDataByUserId(String userId) {
|
||||||
|
try {
|
||||||
|
log.debug("매장 정보 실시간 조회: userId={}", userId);
|
||||||
|
return callStoreServiceByUserId(userId);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
|
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
|
||||||
return createMockStoreData(storeId);
|
return createMockStoreData(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isStoreServiceAvailable() {
|
private StoreData callStoreServiceByUserId(String userId) {
|
||||||
return !storeServiceBaseUrl.equals("http://localhost:8082");
|
|
||||||
}
|
|
||||||
|
|
||||||
private StoreData callStoreService(Long storeId) {
|
|
||||||
try {
|
try {
|
||||||
StoreApiResponse response = webClient
|
StoreApiResponse response = webClient
|
||||||
.get()
|
.get()
|
||||||
.uri(storeServiceBaseUrl + "/api/store/" + storeId)
|
.uri(storeServiceBaseUrl + "/api/store")
|
||||||
|
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(StoreApiResponse.class)
|
.bodyToMono(StoreApiResponse.class)
|
||||||
.timeout(Duration.ofMillis(timeout))
|
.timeout(Duration.ofMillis(timeout))
|
||||||
.block();
|
.block();
|
||||||
|
|
||||||
|
log.info("response : {}", response.getData().getStoreName());
|
||||||
|
log.info("response : {}", response.getData().getStoreId());
|
||||||
|
|
||||||
if (response != null && response.getData() != null) {
|
if (response != null && response.getData() != null) {
|
||||||
StoreApiResponse.StoreInfo storeInfo = response.getData();
|
StoreApiResponse.StoreInfo storeInfo = response.getData();
|
||||||
return StoreData.builder()
|
return StoreData.builder()
|
||||||
|
.storeId(storeInfo.getStoreId())
|
||||||
.storeName(storeInfo.getStoreName())
|
.storeName(storeInfo.getStoreName())
|
||||||
.businessType(storeInfo.getBusinessType())
|
.businessType(storeInfo.getBusinessType())
|
||||||
.location(storeInfo.getAddress())
|
.location(storeInfo.getAddress())
|
||||||
|
.description(storeInfo.getDescription())
|
||||||
|
.seatCount(storeInfo.getSeatCount())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
} catch (WebClientResponseException e) {
|
} catch (WebClientResponseException e) {
|
||||||
@ -79,17 +89,54 @@ public class StoreApiDataProvider implements StoreDataProvider {
|
|||||||
log.error("매장 서비스 호출 실패: {}", e.getMessage());
|
log.error("매장 서비스 호출 실패: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return createMockStoreData(storeId);
|
return createMockStoreData(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private StoreData createMockStoreData(Long storeId) {
|
private String getCurrentUserId() {
|
||||||
|
try {
|
||||||
|
return SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("사용자 ID 조회 실패: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCurrentJwtToken() {
|
||||||
|
try {
|
||||||
|
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
|
||||||
|
if (attributes == null) {
|
||||||
|
log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServletRequest request = attributes.getRequest();
|
||||||
|
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
|
||||||
|
String token = bearerToken.substring(BEARER_PREFIX.length());
|
||||||
|
log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length())));
|
||||||
|
return token;
|
||||||
|
} else {
|
||||||
|
log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoreData createMockStoreData(String userId) {
|
||||||
return StoreData.builder()
|
return StoreData.builder()
|
||||||
.storeName("테스트 카페 " + storeId)
|
.storeName("테스트 카페 " + userId)
|
||||||
.businessType("카페")
|
.businessType("카페")
|
||||||
.location("서울시 강남구")
|
.location("서울시 강남구")
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
private static class StoreApiResponse {
|
private static class StoreApiResponse {
|
||||||
private int status;
|
private int status;
|
||||||
private String message;
|
private String message;
|
||||||
@ -102,23 +149,14 @@ public class StoreApiDataProvider implements StoreDataProvider {
|
|||||||
public StoreInfo getData() { return data; }
|
public StoreInfo getData() { return data; }
|
||||||
public void setData(StoreInfo data) { this.data = data; }
|
public void setData(StoreInfo data) { this.data = data; }
|
||||||
|
|
||||||
|
@Getter
|
||||||
static class StoreInfo {
|
static class StoreInfo {
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
private String storeName;
|
private String storeName;
|
||||||
private String businessType;
|
private String businessType;
|
||||||
private String address;
|
private String address;
|
||||||
private String phoneNumber;
|
private String description;
|
||||||
|
private Integer seatCount;
|
||||||
public Long getStoreId() { return storeId; }
|
|
||||||
public void setStoreId(Long storeId) { this.storeId = storeId; }
|
|
||||||
public String getStoreName() { return storeName; }
|
|
||||||
public void setStoreName(String storeName) { this.storeName = storeName; }
|
|
||||||
public String getBusinessType() { return businessType; }
|
|
||||||
public void setBusinessType(String businessType) { this.businessType = businessType; }
|
|
||||||
public String getAddress() { return address; }
|
|
||||||
public void setAddress(String address) { this.address = address; }
|
|
||||||
public String getPhoneNumber() { return phoneNumber; }
|
|
||||||
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -8,13 +8,14 @@ import lombok.Builder;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import org.springframework.data.annotation.CreatedDate;
|
import org.springframework.data.annotation.CreatedDate;
|
||||||
|
import org.springframework.data.annotation.LastModifiedDate;
|
||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 JPA 엔티티 (날씨 정보 제거)
|
* 마케팅 팁 JPA 엔티티
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "marketing_tips")
|
@Table(name = "marketing_tips")
|
||||||
@ -27,53 +28,54 @@ public class MarketingTipEntity {
|
|||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "tip_id", nullable = false)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false, length = 50)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
@Column(name = "store_id", nullable = false)
|
@Column(name = "store_id", nullable = false)
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
|
|
||||||
@Column(name = "tip_content", nullable = false, length = 2000)
|
@Column(name = "tip_summary")
|
||||||
|
private String tipSummary;
|
||||||
|
|
||||||
|
@Lob
|
||||||
|
@Column(name = "tip_content", nullable = false, columnDefinition = "TEXT")
|
||||||
private String tipContent;
|
private String tipContent;
|
||||||
|
|
||||||
// 매장 정보만 저장
|
@Column(name = "ai_model")
|
||||||
@Column(name = "store_name", length = 200)
|
private String aiModel;
|
||||||
private String storeName;
|
|
||||||
|
|
||||||
@Column(name = "business_type", length = 100)
|
|
||||||
private String businessType;
|
|
||||||
|
|
||||||
@Column(name = "store_location", length = 500)
|
|
||||||
private String storeLocation;
|
|
||||||
|
|
||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
public static MarketingTipEntity fromDomain(MarketingTip marketingTip) {
|
@LastModifiedDate
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public static MarketingTipEntity fromDomain(MarketingTip marketingTip, String userId) {
|
||||||
return MarketingTipEntity.builder()
|
return MarketingTipEntity.builder()
|
||||||
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
|
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
|
||||||
|
.userId(userId)
|
||||||
.storeId(marketingTip.getStoreId())
|
.storeId(marketingTip.getStoreId())
|
||||||
.tipContent(marketingTip.getTipContent())
|
.tipContent(marketingTip.getTipContent())
|
||||||
.storeName(marketingTip.getStoreData().getStoreName())
|
.tipSummary(marketingTip.getTipSummary())
|
||||||
.businessType(marketingTip.getStoreData().getBusinessType())
|
|
||||||
.storeLocation(marketingTip.getStoreData().getLocation())
|
|
||||||
.createdAt(marketingTip.getCreatedAt())
|
.createdAt(marketingTip.getCreatedAt())
|
||||||
|
.updatedAt(marketingTip.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public MarketingTip toDomain() {
|
|
||||||
StoreData storeData = StoreData.builder()
|
|
||||||
.storeName(this.storeName)
|
|
||||||
.businessType(this.businessType)
|
|
||||||
.location(this.storeLocation)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
|
public MarketingTip toDomain(StoreData storeData) {
|
||||||
return MarketingTip.builder()
|
return MarketingTip.builder()
|
||||||
.id(this.id != null ? TipId.of(this.id) : null)
|
.id(this.id != null ? TipId.of(this.id) : null)
|
||||||
.storeId(this.storeId)
|
.storeId(this.storeId)
|
||||||
|
.tipSummary(this.tipSummary)
|
||||||
.tipContent(this.tipContent)
|
.tipContent(this.tipContent)
|
||||||
.storeData(storeData)
|
|
||||||
.createdAt(this.createdAt)
|
.createdAt(this.createdAt)
|
||||||
|
.updatedAt(this.updatedAt)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,34 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 JPA 레포지토리
|
* 마케팅 팁 JPA 레포지토리
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
|
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장별 마케팅 팁 조회 (기존 - storeId 기반)
|
||||||
|
*/
|
||||||
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
|
||||||
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
|
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자별 마케팅 팁 조회 (새로 추가 - userId 기반)
|
||||||
|
*/
|
||||||
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC")
|
||||||
|
Page<MarketingTipEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 가장 최근 마케팅 팁 조회
|
||||||
|
*/
|
||||||
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC LIMIT 1")
|
||||||
|
Optional<MarketingTipEntity> findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 팁이 해당 사용자의 것인지 확인
|
||||||
|
*/
|
||||||
|
boolean existsByIdAndUserId(Long id, String userId);
|
||||||
}
|
}
|
||||||
@ -1,39 +1,88 @@
|
|||||||
package com.won.smarketing.recommend.infrastructure.persistence;
|
package com.won.smarketing.recommend.infrastructure.persistence;
|
||||||
|
|
||||||
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
||||||
|
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
@Slf4j
|
||||||
* 마케팅 팁 레포지토리 구현체
|
|
||||||
*/
|
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
|
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
|
||||||
|
|
||||||
private final MarketingTipJpaRepository jpaRepository;
|
private final MarketingTipJpaRepository jpaRepository;
|
||||||
|
private final StoreDataProvider storeDataProvider;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MarketingTip save(MarketingTip marketingTip) {
|
public MarketingTip save(MarketingTip marketingTip) {
|
||||||
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
|
String userId = getCurrentUserId();
|
||||||
|
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
|
||||||
MarketingTipEntity savedEntity = jpaRepository.save(entity);
|
MarketingTipEntity savedEntity = jpaRepository.save(entity);
|
||||||
return savedEntity.toDomain();
|
|
||||||
|
// Store 정보는 다시 조회해서 Domain에 설정
|
||||||
|
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
|
||||||
|
return savedEntity.toDomain(storeData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<MarketingTip> findById(Long tipId) {
|
public Optional<MarketingTip> findById(Long tipId) {
|
||||||
return jpaRepository.findById(tipId)
|
return jpaRepository.findById(tipId)
|
||||||
.map(MarketingTipEntity::toDomain);
|
.map(entity -> {
|
||||||
|
// Store 정보를 API로 조회
|
||||||
|
StoreData storeData = storeDataProvider.getStoreDataByUserId(entity.getUserId());
|
||||||
|
return entity.toDomain(storeData);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
|
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
|
||||||
return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable)
|
// 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회
|
||||||
.map(MarketingTipEntity::toDomain);
|
String userId = getCurrentUserId();
|
||||||
|
return findByUserIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자별 마케팅 팁 조회 (새로 추가)
|
||||||
|
*/
|
||||||
|
public Page<MarketingTip> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) {
|
||||||
|
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
|
||||||
|
// Store 정보는 한 번만 조회 (같은 userId이므로)
|
||||||
|
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
|
||||||
|
|
||||||
|
return entities.map(entity -> entity.toDomain(storeData));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 가장 최근 마케팅 팁 조회
|
||||||
|
*/
|
||||||
|
public Optional<MarketingTip> findMostRecentByUserId(String userId) {
|
||||||
|
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
|
||||||
|
.map(entity -> {
|
||||||
|
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
|
||||||
|
return entity.toDomain(storeData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 팁이 해당 사용자의 것인지 확인
|
||||||
|
*/
|
||||||
|
public boolean isOwnedByUser(Long tipId, String userId) {
|
||||||
|
return jpaRepository.existsByIdAndUserId(tipId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인된 사용자 ID 조회
|
||||||
|
*/
|
||||||
|
private String getCurrentUserId() {
|
||||||
|
return SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,22 +2,18 @@ package com.won.smarketing.recommend.presentation.controller;
|
|||||||
|
|
||||||
import com.won.smarketing.common.dto.ApiResponse;
|
import com.won.smarketing.common.dto.ApiResponse;
|
||||||
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
|
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
|
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.data.domain.Page;
|
|
||||||
import org.springframework.data.domain.Pageable;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 마케팅 추천 컨트롤러
|
* AI 마케팅 추천 컨트롤러 (단일 API)
|
||||||
*/
|
*/
|
||||||
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
|
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -29,49 +25,17 @@ public class RecommendationController {
|
|||||||
private final MarketingTipUseCase marketingTipUseCase;
|
private final MarketingTipUseCase marketingTipUseCase;
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "AI 마케팅 팁 생성",
|
summary = "마케팅 팁 조회/생성",
|
||||||
description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다."
|
description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
|
||||||
)
|
)
|
||||||
@PostMapping("/marketing-tips")
|
@PostMapping("/marketing-tips")
|
||||||
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
|
public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
|
||||||
@Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) {
|
|
||||||
|
|
||||||
log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId());
|
log.info("마케팅 팁 제공 요청");
|
||||||
|
|
||||||
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
|
MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
|
||||||
|
|
||||||
log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId());
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "마케팅 팁 이력 조회",
|
|
||||||
description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다."
|
|
||||||
)
|
|
||||||
@GetMapping("/marketing-tips")
|
|
||||||
public ResponseEntity<ApiResponse<Page<MarketingTipResponse>>> getMarketingTipHistory(
|
|
||||||
@Parameter(description = "매장 ID") @RequestParam Long storeId,
|
|
||||||
Pageable pageable) {
|
|
||||||
|
|
||||||
log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber());
|
|
||||||
|
|
||||||
Page<MarketingTipResponse> response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(
|
|
||||||
summary = "마케팅 팁 상세 조회",
|
|
||||||
description = "특정 마케팅 팁의 상세 정보를 조회합니다."
|
|
||||||
)
|
|
||||||
@GetMapping("/marketing-tips/{tipId}")
|
|
||||||
public ResponseEntity<ApiResponse<MarketingTipResponse>> getMarketingTip(
|
|
||||||
@Parameter(description = "팁 ID") @PathVariable Long tipId) {
|
|
||||||
|
|
||||||
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
|
|
||||||
|
|
||||||
MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId);
|
|
||||||
|
|
||||||
|
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
|
||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
package com.won.smarketing.recommend.presentation.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import jakarta.validation.constraints.Positive;
|
|
||||||
|
|
||||||
@Schema(description = "마케팅 팁 생성 요청")
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class MarketingTipRequest {
|
|
||||||
|
|
||||||
@Schema(description = "매장 ID", example = "1", required = true)
|
|
||||||
@NotNull(message = "매장 ID는 필수입니다")
|
|
||||||
@Positive(message = "매장 ID는 양수여야 합니다")
|
|
||||||
private Long storeId;
|
|
||||||
|
|
||||||
@Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요")
|
|
||||||
private String additionalRequirement;
|
|
||||||
}
|
|
||||||
@ -8,43 +8,50 @@ import lombok.NoArgsConstructor;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Schema(description = "마케팅 팁 응답")
|
/**
|
||||||
|
* 마케팅 팁 응답 DTO (요약 + 상세 통합)
|
||||||
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "마케팅 팁 응답")
|
||||||
public class MarketingTipResponse {
|
public class MarketingTipResponse {
|
||||||
|
|
||||||
@Schema(description = "팁 ID", example = "1")
|
@Schema(description = "팁 ID", example = "1")
|
||||||
private Long tipId;
|
private Long tipId;
|
||||||
|
|
||||||
@Schema(description = "매장 ID", example = "1")
|
@Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
|
||||||
private Long storeId;
|
private String tipSummary;
|
||||||
|
|
||||||
@Schema(description = "매장명", example = "카페 봄날")
|
@Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
|
||||||
private String storeName;
|
|
||||||
|
|
||||||
@Schema(description = "AI 생성 마케팅 팁 내용")
|
|
||||||
private String tipContent;
|
private String tipContent;
|
||||||
|
|
||||||
@Schema(description = "매장 정보")
|
@Schema(description = "매장 정보")
|
||||||
private StoreInfo storeInfo;
|
private StoreInfo storeInfo;
|
||||||
|
|
||||||
@Schema(description = "생성 일시")
|
@Schema(description = "생성 시간", example = "2025-06-13T14:30:00")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "수정 시간", example = "2025-06-13T14:30:00")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Schema(description = "1시간 이내 생성 여부", example = "true")
|
||||||
|
private boolean isRecentlyCreated;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "매장 정보")
|
||||||
public static class StoreInfo {
|
public static class StoreInfo {
|
||||||
@Schema(description = "매장명", example = "카페 봄날")
|
@Schema(description = "매장명", example = "민코의 카페")
|
||||||
private String storeName;
|
private String storeName;
|
||||||
|
|
||||||
@Schema(description = "업종", example = "카페")
|
@Schema(description = "업종", example = "카페")
|
||||||
private String businessType;
|
private String businessType;
|
||||||
|
|
||||||
@Schema(description = "위치", example = "서울시 강남구")
|
@Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
|
||||||
private String location;
|
private String location;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,7 +12,7 @@ spring:
|
|||||||
password: ${POSTGRES_PASSWORD:postgres}
|
password: ${POSTGRES_PASSWORD:postgres}
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
ddl-auto: ${JPA_DDL_AUTO:create-drop}
|
||||||
show-sql: ${JPA_SHOW_SQL:true}
|
show-sql: ${JPA_SHOW_SQL:true}
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
@ -29,7 +29,7 @@ external:
|
|||||||
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
|
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
|
||||||
timeout: ${STORE_SERVICE_TIMEOUT:5000}
|
timeout: ${STORE_SERVICE_TIMEOUT:5000}
|
||||||
python-ai-service:
|
python-ai-service:
|
||||||
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:8090}
|
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
|
||||||
api-key: ${PYTHON_AI_API_KEY:dummy-key}
|
api-key: ${PYTHON_AI_API_KEY:dummy-key}
|
||||||
timeout: ${PYTHON_AI_TIMEOUT:30000}
|
timeout: ${PYTHON_AI_TIMEOUT:30000}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user