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.StoreData;
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.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 마케팅 서비스 구현체
*/
import java.time.LocalDateTime;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
@ -32,70 +32,134 @@ public class MarketingTipService implements MarketingTipUseCase {
private final AiTipGenerator aiTipGenerator;
@Override
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId());
public MarketingTipResponse provideMarketingTip() {
String userId = getCurrentUserId();
log.info("마케팅 팁 제공: userId={}", userId);
try {
// 1. 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 1. 사용자의 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
// 2. Python AI 서비스로 생성 (매장 정보 + 추가 요청사항 전달)
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement());
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeData.getStoreId());
// 3. 도메인 객체 생성 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(request.getStoreId())
.tipContent(aiGeneratedTip)
.storeData(storeData)
.build();
if (recentTip.isPresent()) {
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
return convertToResponse(recentTip.get(), storeData, true);
}
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
return convertToResponse(savedTip);
// 3. 1시간 이내 팁이 없으면 새로 생성
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeData.getStoreId());
MarketingTip newTip = createNewMarketingTip(storeData);
return convertToResponse(newTip, storeData, false);
} catch (Exception e) {
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
@Override
@Transactional(readOnly = true)
@Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize")
public Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable) {
log.info("마케팅 팁 이력 조회: storeId={}", storeId);
/**
* DB에서 1시간 이내 생성된 마케팅 조회
*/
private Optional<MarketingTip> findRecentMarketingTip(Long 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)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR));
// AI 서비스로 생성
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()
.tipId(marketingTip.getId().getValue())
.storeId(marketingTip.getStoreId())
.storeName(marketingTip.getStoreData().getStoreName())
.tipContent(marketingTip.getTipContent())
.tipSummary(tipSummary)
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
.storeInfo(MarketingTipResponse.StoreInfo.builder()
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.location(marketingTip.getStoreData().getLocation())
.storeName(storeData.getStoreName())
.businessType(storeData.getBusinessType())
.location(storeData.getLocation())
.build())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.isRecentlyCreated(isRecentlyCreated)
.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;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/**
* 마케팅 유즈케이스 인터페이스
*/
public interface MarketingTipUseCase {
/**
* AI 마케팅 생성
* 마케팅 제공
* 1시간 이내 팁이 있으면 기존 사용, 없으면 새로 생성
*/
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
/**
* 마케팅 이력 조회
*/
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
/**
* 마케팅 상세 조회
*/
MarketingTipResponse getMarketingTip(Long tipId);
MarketingTipResponse provideMarketingTip();
}

View File

@ -4,6 +4,7 @@ import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.cglib.core.Local;
import java.time.LocalDateTime;
@ -18,9 +19,11 @@ public class MarketingTip {
private TipId id;
private Long storeId;
private String tipSummary;
private String tipContent;
private StoreData storeData;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) {
return MarketingTip.builder()

View File

@ -13,7 +13,10 @@ import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
private Long storeId;
private String storeName;
private String businessType;
private String location;
private String description;
private Integer seatCount;
}

View File

@ -11,8 +11,7 @@ public interface AiTipGenerator {
* Python AI 서비스를 통한 마케팅 생성
*
* @param storeData 매장 정보
* @param additionalRequirement 추가 요청사항
* @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 {
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.service.AiTipGenerator;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
/**
@ -31,36 +33,25 @@ public class PythonAiTipGenerator implements AiTipGenerator {
private int timeout;
@Override
public String generateTip(StoreData storeData, String additionalRequirement) {
public String generateTip(StoreData storeData) {
try {
log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName());
// Python AI 서비스 사용 가능 여부 확인
if (isPythonServiceAvailable()) {
return callPythonAiService(storeData, additionalRequirement);
} else {
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
return createFallbackTip(storeData, additionalRequirement);
}
log.debug("Python AI 서비스 직접 호출: store={}", storeData.getStoreName());
return callPythonAiService(storeData);
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData, additionalRequirement);
return createFallbackTip(storeData);
}
}
private boolean isPythonServiceAvailable() {
return !pythonAiServiceApiKey.equals("dummy-key");
}
private String callPythonAiService(StoreData storeData, String additionalRequirement) {
private String callPythonAiService(StoreData storeData) {
try {
// Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달)
// Python AI 서비스로 전송할 데이터
Map<String, Object> requestData = Map.of(
"store_name", storeData.getStoreName(),
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(),
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
"seat_count", storeData.getSeatCount()
);
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
@ -84,23 +75,17 @@ public class PythonAiTipGenerator implements AiTipGenerator {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeData, additionalRequirement);
return createFallbackTip(storeData);
}
/**
* 규칙 기반 Fallback 생성 (날씨 정보 없이 매장 정보만 활용)
*/
private String createFallbackTip(StoreData storeData, String additionalRequirement) {
private String createFallbackTip(StoreData storeData) {
String businessType = storeData.getBusinessType();
String storeName = storeData.getStoreName();
String location = storeData.getLocation();
// 추가 요청사항이 있는 경우 우선 반영
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!",
storeName, additionalRequirement);
}
// 업종별 기본 생성
if (businessType.contains("카페")) {
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
@ -123,16 +108,13 @@ public class PythonAiTipGenerator implements AiTipGenerator {
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
}
@Getter
private static class PythonAiResponse {
private String tip;
private String status;
private String message;
public String getTip() { return tip; }
public void setTip(String tip) { this.tip = tip; }
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; }
private LocalDateTime generatedTip;
private String businessType;
private String aiModel;
}
}

View File

@ -4,11 +4,16 @@ import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import jakarta.servlet.http.HttpServletRequest;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; // 어노테이션이 누락되어 있었음
import org.springframework.security.core.context.SecurityContextHolder;
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.WebClientResponseException;
@ -30,46 +35,51 @@ public class StoreApiDataProvider implements StoreDataProvider {
@Value("${external.store-service.timeout}")
private int timeout;
@Override
@Cacheable(value = "storeData", key = "#storeId")
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시도: storeId={}", storeId);
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
// 외부 서비스 연결 시도, 실패 Mock 데이터 반환
if (isStoreServiceAvailable()) {
return callStoreService(storeId);
} else {
log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId);
return createMockStoreData(storeId);
}
/**
* 사용자 ID로 매장 정보 조회
*
* @param userId 사용자 ID
* @return 매장 정보
*/
@Override
public StoreData getStoreDataByUserId(String userId) {
try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
return callStoreServiceByUserId(userId);
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockStoreData(storeId);
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
return createMockStoreData(userId);
}
}
private boolean isStoreServiceAvailable() {
return !storeServiceBaseUrl.equals("http://localhost:8082");
}
private StoreData callStoreServiceByUserId(String userId) {
private StoreData callStoreService(Long storeId) {
try {
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store/" + storeId)
.uri(storeServiceBaseUrl + "/api/store")
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
log.info("response : {}", response.getData().getStoreName());
log.info("response : {}", response.getData().getStoreId());
if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder()
.storeId(storeInfo.getStoreId())
.storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress())
.description(storeInfo.getDescription())
.seatCount(storeInfo.getSeatCount())
.build();
}
} catch (WebClientResponseException e) {
@ -79,17 +89,54 @@ public class StoreApiDataProvider implements StoreDataProvider {
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()
.storeName("테스트 카페 " + storeId)
.storeName("테스트 카페 " + userId)
.businessType("카페")
.location("서울시 강남구")
.build();
}
@Getter
private static class StoreApiResponse {
private int status;
private String message;
@ -102,23 +149,14 @@ public class StoreApiDataProvider implements StoreDataProvider {
public StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; }
@Getter
static class StoreInfo {
private Long storeId;
private String storeName;
private String businessType;
private String address;
private String phoneNumber;
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; }
private String description;
private Integer seatCount;
}
}
}

View File

@ -8,13 +8,14 @@ import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* 마케팅 JPA 엔티티 (날씨 정보 제거)
* 마케팅 JPA 엔티티
*/
@Entity
@Table(name = "marketing_tips")
@ -27,53 +28,54 @@ public class MarketingTipEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "tip_id", nullable = false)
private Long id;
@Column(name = "user_id", nullable = false, length = 50)
private String userId;
@Column(name = "store_id", nullable = false)
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;
// 매장 정보만 저장
@Column(name = "store_name", length = 200)
private String storeName;
@Column(name = "business_type", length = 100)
private String businessType;
@Column(name = "store_location", length = 500)
private String storeLocation;
@Column(name = "ai_model")
private String aiModel;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
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()
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
.userId(userId)
.storeId(marketingTip.getStoreId())
.tipContent(marketingTip.getTipContent())
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.storeLocation(marketingTip.getStoreData().getLocation())
.tipSummary(marketingTip.getTipSummary())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.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()
.id(this.id != null ? TipId.of(this.id) : null)
.storeId(this.storeId)
.tipSummary(this.tipSummary)
.tipContent(this.tipContent)
.storeData(storeData)
.createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build();
}
}

View File

@ -7,12 +7,34 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 마케팅 JPA 레포지토리
*/
@Repository
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
/**
* 매장별 마케팅 조회 (기존 - storeId 기반)
*/
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
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;
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.service.StoreDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 마케팅 레포지토리 구현체
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
private final MarketingTipJpaRepository jpaRepository;
private final StoreDataProvider storeDataProvider;
@Override
public MarketingTip save(MarketingTip marketingTip) {
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
String userId = getCurrentUserId();
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
MarketingTipEntity savedEntity = jpaRepository.save(entity);
return savedEntity.toDomain();
// Store 정보는 다시 조회해서 Domain에 설정
StoreData storeData = storeDataProvider.getStoreDataByUserId(userId);
return savedEntity.toDomain(storeData);
}
@Override
public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId)
.map(MarketingTipEntity::toDomain);
.map(entity -> {
// Store 정보를 API로 조회
StoreData storeData = storeDataProvider.getStoreDataByUserId(entity.getUserId());
return entity.toDomain(storeData);
});
}
@Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable)
.map(MarketingTipEntity::toDomain);
// 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회
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.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
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.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* AI 마케팅 추천 컨트롤러
* AI 마케팅 추천 컨트롤러 (단일 API)
*/
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j
@ -29,49 +25,17 @@ public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
@Operation(
summary = "AI 마케팅 팁 생성",
description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다."
summary = "마케팅 팁 조회/생성",
description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
)
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
@Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) {
public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId());
log.info("마케팅 팁 제공 요청");
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
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);
MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
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;
@Schema(description = "마케팅 팁 응답")
/**
* 마케팅 응답 DTO (요약 + 상세 통합)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "마케팅 팁 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
private String tipSummary;
@Schema(description = "매장명", example = "카페 봄날")
private String storeName;
@Schema(description = "AI 생성 마케팅 팁 내용")
@Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
private String tipContent;
@Schema(description = "매장 정보")
private StoreInfo storeInfo;
@Schema(description = "생성 ")
@Schema(description = "생성 ", example = "2025-06-13T14:30:00")
private LocalDateTime createdAt;
@Schema(description = "수정 시간", example = "2025-06-13T14:30:00")
private LocalDateTime updatedAt;
@Schema(description = "1시간 이내 생성 여부", example = "true")
private boolean isRecentlyCreated;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 정보")
public static class StoreInfo {
@Schema(description = "매장명", example = "카페 봄날")
@Schema(description = "매장명", example = "민코의 카페")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "위치", example = "서울시 강남구")
@Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
private String location;
}
}

View File

@ -12,7 +12,7 @@ spring:
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
ddl-auto: ${JPA_DDL_AUTO:create-drop}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
@ -29,7 +29,7 @@ external:
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
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}
timeout: ${PYTHON_AI_TIMEOUT:30000}