mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2026-06-13 04:49:10 +00:00
release
This commit is contained in:
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+168
@@ -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();
|
||||
}
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+13
@@ -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 캐시 사용
|
||||
}
|
||||
+12
@@ -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 {
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+13
@@ -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);
|
||||
}
|
||||
+143
@@ -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;
|
||||
}
|
||||
}
|
||||
+311
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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();
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+88
@@ -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();
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+57
@@ -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}
|
||||
Reference in New Issue
Block a user