feat: marketing tip

This commit is contained in:
unknown 2025-06-16 11:15:16 +09:00
parent 0b4c543b19
commit b5d896aadf
15 changed files with 365 additions and 267 deletions

View File

@ -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();
} }
@Override MarketingTip mostRecentTip = recentTips.getContent().get(0);
@Transactional(readOnly = true) LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
public MarketingTipResponse getMarketingTip(Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTip marketingTip = marketingTipRepository.findById(tipId) // 1시간 이내에 생성된 팁인지 확인
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR)); if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) {
log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}",
return convertToResponse(marketingTip); mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.of(mostRecentTip);
} }
private MarketingTipResponse convertToResponse(MarketingTip marketingTip) { log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}",
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
return Optional.empty();
}
/**
* 새로운 마케팅 생성
*/
private MarketingTip createNewMarketingTip(StoreData storeData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeData.getStoreName());
// AI 서비스로 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 도메인 객체 생성 저장
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;
}
/**
* 마케팅 팁을 응답 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();
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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