This commit is contained in:
OhSeongRak
2025-06-17 10:05:16 +09:00
commit 44d7312a85
178 changed files with 15106 additions and 0 deletions
@@ -0,0 +1,20 @@
package com.won.smarketing.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = {
"com.won.smarketing.recommend",
"com.won.smarketing.common"
})
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence")
@EnableCaching
public class AIRecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AIRecommendServiceApplication.class, args);
}
}
@@ -0,0 +1,168 @@
package com.won.smarketing.recommend.application.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
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.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 java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class MarketingTipService implements MarketingTipUseCase {
private final MarketingTipRepository marketingTipRepository;
private final StoreDataProvider storeDataProvider;
private final AiTipGenerator aiTipGenerator;
@Override
public MarketingTipResponse provideMarketingTip() {
String userId = getCurrentUserId();
log.info("마케팅 팁 제공: userId={}", userId);
try {
// 1. 사용자의 매장 정보 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId());
if (recentTip.isPresent()) {
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true);
}
// 3. 1시간 이내 팁이 없으면 새로 생성
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
} catch (Exception e) {
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
/**
* DB에서 1시간 이내 생성된 마케팅 팁 조회
*/
private Optional<MarketingTip> findRecentMarketingTip(Long storeId) {
log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId);
// 최근 생성된 팁 1개 조회
Pageable pageable = PageRequest.of(0, 1);
Page<MarketingTip> recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
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();
}
/**
* 새로운 마케팅 팁 생성
*/
private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
// AI 서비스로 팁 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 도메인 객체 생성 및 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(storeWithMenuData.getStoreData().getStoreId())
.tipContent(aiGeneratedTip)
.storeWithMenuData(storeWithMenuData)
.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()
.tipId(marketingTip.getId().getValue())
.tipSummary(tipSummary)
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
.storeInfo(MarketingTipResponse.StoreInfo.builder()
.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();
}
}
@@ -0,0 +1,12 @@
package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
public interface MarketingTipUseCase {
/**
* 마케팅 팁 제공
* 1시간 이내 팁이 있으면 기존 것 사용, 없으면 새로 생성
*/
MarketingTipResponse provideMarketingTip();
}
@@ -0,0 +1,13 @@
package com.won.smarketing.recommend.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
/**
* 캐시 설정
*/
@Configuration
@EnableCaching
public class CacheConfig {
// 기본 Simple 캐시 사용
}
@@ -0,0 +1,12 @@
package com.won.smarketing.recommend.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정
*/
@Configuration
@EnableJpaRepositories
public class JpaConfig {
}
@@ -0,0 +1,29 @@
package com.won.smarketing.recommend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* WebClient 설정 (간소화된 버전)
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000)
.responseTimeout(Duration.ofMillis(30000));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
}
@@ -0,0 +1,36 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.cglib.core.Local;
import java.time.LocalDateTime;
/**
* 마케팅 팁 도메인 모델 (날씨 정보 제거)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTip {
private TipId id;
private Long storeId;
private String tipSummary;
private String tipContent;
private StoreWithMenuData storeWithMenuData;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) {
return MarketingTip.builder()
.storeId(storeId)
.tipContent(tipContent)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
}
}
@@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 메뉴 데이터 값 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MenuData {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
}
@@ -0,0 +1,22 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 데이터 값 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
private Long storeId;
private String storeName;
private String businessType;
private String location;
private String description;
private Integer seatCount;
}
@@ -0,0 +1,13 @@
package com.won.smarketing.recommend.domain.model;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class StoreWithMenuData {
private StoreData storeData;
private List<MenuData> menuDataList;
}
@@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 팁 ID 값 객체
*/
@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class TipId {
private Long value;
public static TipId of(Long value) {
return new TipId(value);
}
}
@@ -0,0 +1,19 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
/**
* 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스)
*/
public interface MarketingTipRepository {
MarketingTip save(MarketingTip marketingTip);
Optional<MarketingTip> findById(Long tipId);
Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable);
}
@@ -0,0 +1,18 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
/**
* AI 팁 생성 도메인 서비스 인터페이스 (단순화)
*/
public interface AiTipGenerator {
/**
* Python AI 서비스를 통한 마케팅 팁 생성
*
* @param storeWithMenuData 매장 및 메뉴 정보
* @return AI가 생성한 마케팅 팁
*/
String generateTip(StoreWithMenuData storeWithMenuData);
}
@@ -0,0 +1,13 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
import java.util.List;
/**
* 매장 데이터 제공 도메인 서비스 인터페이스
*/
public interface StoreDataProvider {
StoreWithMenuData getStoreWithMenuData(String userId);
}
@@ -0,0 +1,143 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
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;
import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Python AI 팁 생성 구현체 (날씨 정보 제거)
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class PythonAiTipGenerator implements AiTipGenerator {
private final WebClient webClient;
@Value("${external.python-ai-service.base-url}")
private String pythonAiServiceBaseUrl;
@Value("${external.python-ai-service.api-key}")
private String pythonAiServiceApiKey;
@Value("${external.python-ai-service.timeout}")
private int timeout;
@Override
public String generateTip(StoreWithMenuData storeWithMenuData) {
try {
log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
return callPythonAiService(storeWithMenuData);
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeWithMenuData);
}
}
private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
try {
StoreData storeData = storeWithMenuData.getStoreData();
List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
// 메뉴 데이터를 Map 형태로 변환
List<Map<String, Object>> menuList = menuDataList.stream()
.map(menu -> {
Map<String, Object> menuMap = new HashMap<>();
menuMap.put("menu_id", menu.getMenuId());
menuMap.put("menu_name", menu.getMenuName());
menuMap.put("category", menu.getCategory());
menuMap.put("price", menu.getPrice());
menuMap.put("description", menu.getDescription());
return menuMap;
})
.collect(Collectors.toList());
// Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보)
Map<String, Object> requestData = new HashMap<>();
requestData.put("store_name", storeData.getStoreName());
requestData.put("business_type", storeData.getBusinessType());
requestData.put("location", storeData.getLocation());
requestData.put("seat_count", storeData.getSeatCount());
requestData.put("menu_list", menuList);
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
PythonAiResponse response = webClient
.post()
.uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip")
.header("Authorization", "Bearer " + pythonAiServiceApiKey)
.header("Content-Type", "application/json")
.bodyValue(requestData)
.retrieve()
.bodyToMono(PythonAiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) {
log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length());
return response.getTip();
}
} catch (Exception e) {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeWithMenuData);
}
/**
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
*/
private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
String businessType = storeWithMenuData.getStoreData().getBusinessType();
String storeName = storeWithMenuData.getStoreData().getStoreName();
String location = storeWithMenuData.getStoreData().getLocation();
// 업종별 기본 팁 생성
if (businessType.contains("카페")) {
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
} else if (businessType.contains("음식점") || businessType.contains("식당")) {
return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName);
} else if (businessType.contains("베이커리") || businessType.contains("빵집")) {
return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName);
} else if (businessType.contains("치킨") || businessType.contains("튀김")) {
return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName);
}
// 지역별 팁
if (location.contains("강남") || location.contains("서초")) {
return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName);
} else if (location.contains("홍대") || location.contains("신촌")) {
return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName);
}
// 기본 팁
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
}
@Getter
private static class PythonAiResponse {
private String tip;
private String status;
private String message;
private LocalDateTime generatedTip;
private String businessType;
private String aiModel;
}
}
@@ -0,0 +1,311 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.MenuData;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
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.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.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 매장 API 데이터 제공자 구현체
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class StoreApiDataProvider implements StoreDataProvider {
private final WebClient webClient;
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
@Value("${external.store-service.timeout}")
private int timeout;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
public StoreWithMenuData getStoreWithMenuData(String userId) {
log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
try {
// 매장 정보와 메뉴 정보를 병렬로 조회
StoreData storeData = getStoreDataByUserId(userId);
List<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
StoreWithMenuData result = StoreWithMenuData.builder()
.storeData(storeData)
.menuDataList(menuDataList)
.build();
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
return result;
} catch (Exception e) {
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
// 실패 시 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
}
}
public StoreData getStoreDataByUserId(String userId) {
try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
return callStoreServiceByUserId(userId);
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
return createMockStoreData(userId);
}
}
public List<MenuData> getMenusByStoreId(Long storeId) {
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
try {
return callMenuService(storeId);
} catch (Exception e) {
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockMenuData(storeId);
}
}
private StoreData callStoreServiceByUserId(String userId) {
try {
StoreApiResponse response = webClient
.get()
.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) {
if (e.getStatusCode().value() == 404) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
log.error("매장 서비스 호출 실패: {}", e.getMessage());
}
return createMockStoreData(userId);
}
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 List<MenuData> callMenuService(Long storeId) {
try {
MenuApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
.retrieve()
.bodyToMono(MenuApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
List<MenuData> menuDataList = response.getData().stream()
.map(this::toMenuData)
.collect(Collectors.toList());
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
return menuDataList;
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
return Collections.emptyList();
}
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
} catch (WebClientException e) {
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
}
return createMockMenuData(storeId);
}
/**
* MenuResponse를 MenuData로 변환
*/
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
return MenuData.builder()
.menuId(menuInfo.getMenuId())
.menuName(menuInfo.getMenuName())
.category(menuInfo.getCategory())
.price(menuInfo.getPrice())
.description(menuInfo.getDescription())
.build();
}
private StoreData createMockStoreData(String userId) {
return StoreData.builder()
.storeName("테스트 카페 " + userId)
.businessType("카페")
.location("서울시 강남구")
.build();
}
private List<MenuData> createMockMenuData(Long storeId) {
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
return List.of(
MenuData.builder()
.menuId(1L)
.menuName("아메리카노")
.category("음료")
.price(4000)
.description("깊고 진한 맛의 아메리카노")
.build(),
MenuData.builder()
.menuId(2L)
.menuName("카페라떼")
.category("음료")
.price(4500)
.description("부드러운 우유 거품이 올라간 카페라떼")
.build(),
MenuData.builder()
.menuId(3L)
.menuName("치즈케이크")
.category("디저트")
.price(6000)
.description("진한 치즈 맛의 수제 케이크")
.build()
);
}
@Getter
private static class StoreApiResponse {
private int status;
private String message;
private StoreInfo data;
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
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 description;
private Integer seatCount;
}
}
/**
* Menu API 응답 DTO (새로 추가)
*/
private static class MenuApiResponse {
private List<MenuInfo> data;
private String message;
private boolean success;
public List<MenuInfo> getData() { return data; }
public void setData(List<MenuInfo> data) { this.data = data; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public static class MenuInfo {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
private String image;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getMenuId() { return menuId; }
public void setMenuId(Long menuId) { this.menuId = menuId; }
public String getMenuName() { return menuName; }
public void setMenuName(String menuName) { this.menuName = menuName; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
}
}
@@ -0,0 +1,81 @@
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.model.TipId;
import lombok.AllArgsConstructor;
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 엔티티
*/
@Entity
@Table(name = "marketing_tips")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
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_summary")
private String tipSummary;
@Lob
@Column(name = "tip_content", nullable = false, columnDefinition = "TEXT")
private String tipContent;
@Column(name = "ai_model")
private String aiModel;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@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())
.tipSummary(marketingTip.getTipSummary())
.createdAt(marketingTip.getCreatedAt())
.updatedAt(marketingTip.getUpdatedAt())
.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)
.createdAt(this.createdAt)
.updatedAt(this.updatedAt)
.build();
}
}
@@ -0,0 +1,40 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.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);
}
@@ -0,0 +1,88 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
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) {
String userId = getCurrentUserId();
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
MarketingTipEntity savedEntity = jpaRepository.save(entity);
// Store 정보는 다시 조회해서 Domain에 설정
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return savedEntity.toDomain(storeWithMenuData.getStoreData());
}
@Override
public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId)
.map(entity -> {
// Store 정보를 API로 조회
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
@Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
// 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 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이므로)
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData()));
}
/**
* 사용자의 가장 최근 마케팅 팁 조회
*/
public Optional<MarketingTip> findMostRecentByUserId(String userId) {
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
.map(entity -> {
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
return entity.toDomain(storeWithMenuData.getStoreData());
});
}
/**
* 특정 팁이 해당 사용자의 것인지 확인
*/
public boolean isOwnedByUser(Long tipId, String userId) {
return jpaRepository.existsByIdAndUserId(tipId, userId);
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
@@ -0,0 +1,34 @@
//package com.won.smarketing.recommend.presentation.controller;
//
//import org.springframework.web.bind.annotation.GetMapping;
//import org.springframework.web.bind.annotation.RestController;
//
//import java.time.LocalDateTime;
//import java.util.Map;
//
///**
// * 헬스체크 컨트롤러
// */
//@RestController
//public class HealthController {
//
// @GetMapping("/health")
// public Map<String, Object> health() {
// return Map.of(
// "status", "UP",
// "service", "ai-recommend-service",
// "timestamp", LocalDateTime.now(),
// "message", "AI 추천 서비스가 정상 동작 중입니다.",
// "features", Map.of(
// "store_integration", "매장 서비스 연동",
// "python_ai_integration", "Python AI 서비스 연동",
// "fallback_support", "Fallback 팁 생성 지원"
// )
// );
// }
//}
// }
//
// } catch (Exception e) {
// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
// return createMockStoreData(storeId);
@@ -0,0 +1,41 @@
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.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* AI 마케팅 추천 컨트롤러 (단일 API)
*/
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j
@RestController
@RequestMapping("/api/recommendations")
@RequiredArgsConstructor
public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
@Operation(
summary = "마케팅 팁 조회/생성",
description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
)
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
log.info("마케팅 팁 제공 요청");
MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response));
}
}
@@ -0,0 +1,57 @@
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 java.time.LocalDateTime;
/**
* 마케팅 팁 응답 DTO (요약 + 상세 통합)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "마케팅 팁 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
private String tipSummary;
@Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
private String tipContent;
@Schema(description = "매장 정보")
private StoreInfo storeInfo;
@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 = "민코의 카페")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
private String location;
}
}
@@ -0,0 +1,52 @@
server:
port: ${SERVER_PORT:8084}
servlet:
context-path: /
spring:
application:
name: ai-recommend-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:create-drop}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
external:
store-service:
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service:
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000}
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
logging:
level:
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}