fix: ai-recommend build

This commit is contained in:
unknown 2025-06-11 17:56:39 +09:00
parent b854885d2e
commit c83ed0d033
30 changed files with 977 additions and 475 deletions

View File

@ -2,18 +2,16 @@ package com.won.smarketing.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
/**
* AI 추천 서비스 메인 애플리케이션 클래스
* Clean Architecture 패턴을 적용한 AI 마케팅 추천 서비스
* AI 추천 서비스 메인 애플리케이션
*/
@SpringBootApplication(scanBasePackages = {"com.won.smarketing.recommend", "com.won.smarketing.common"})
@EntityScan(basePackages = {"com.won.smarketing.recommend.infrastructure.entity"})
@EnableJpaRepositories(basePackages = {"com.won.smarketing.recommend.infrastructure.repository"})
@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
public class AIRecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AIRecommendServiceApplication.class, args);
}

View File

@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.WeatherData;
/**
* Python AI 서비스 인터페이스
* AI 처리를 Python 서비스로 위임하는 도메인 서비스
*/
public interface AiApiService {
/**
* Python AI 서비스를 통한 마케팅 생성
*
* @param storeData 매장 정보
* @param weatherData 날씨 정보
* @param additionalRequirement 추가 요청사항
* @return AI가 생성한 마케팅 ( )
*/
String generateMarketingTip(StoreData storeData, WeatherData weatherData, String additionalRequirement);
}

View File

@ -8,26 +8,26 @@ import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.TipId;
import com.won.smarketing.recommend.domain.model.WeatherData;
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.domain.service.WeatherDataProvider;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
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.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 마케팅 서비스 구현체
* AI 기반 마케팅 생성 저장 기능 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Transactional
public class MarketingTipService implements MarketingTipUseCase {
private final MarketingTipRepository marketingTipRepository;
@ -35,49 +35,95 @@ public class MarketingTipService implements MarketingTipUseCase {
private final WeatherDataProvider weatherDataProvider;
private final AiTipGenerator aiTipGenerator;
/**
* AI 마케팅 생성
*
* @param request 마케팅 생성 요청
* @return 생성된 마케팅 응답
*/
@Override
@Transactional
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId());
try {
// 매장 정보 조회
// 1. 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 날씨 정보 조회
// 2. 날씨 정보 조회
WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation());
log.debug("날씨 정보 조회 완료: {} 도", weatherData.getTemperature());
log.debug("날씨 정보 조회 완료: 온도={}, 상태={}", weatherData.getTemperature(), weatherData.getCondition());
// AI를 사용하여 마케팅 생성
String tipContent = aiTipGenerator.generateTip(storeData, weatherData);
log.debug("AI 마케팅 팁 생성 완료");
// 3. AI 생성
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, weatherData, request.getAdditionalRequirement());
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 마케팅 도메인 객체 생성
// 4. 도메인 객체 생성 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(request.getStoreId())
.tipContent(tipContent)
.tipContent(aiGeneratedTip)
.weatherData(weatherData)
.storeData(storeData)
.createdAt(LocalDateTime.now())
.build();
// 마케팅 저장
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
return MarketingTipResponse.builder()
.tipId(savedTip.getId().getValue())
.tipContent(savedTip.getTipContent())
.createdAt(savedTip.getCreatedAt())
.build();
return convertToResponse(savedTip);
} catch (Exception e) {
log.error("마케팅 팁 생성 중 오류 발생", e);
throw new BusinessException(ErrorCode.RECOMMENDATION_FAILED);
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
throw new BusinessException(ErrorCode.AI_TIP_GENERATION_FAILED);
}
}
}
@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);
Page<MarketingTip> tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
return tips.map(this::convertToResponse);
}
@Override
@Transactional(readOnly = true)
public MarketingTipResponse getMarketingTip(Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTip marketingTip = marketingTipRepository.findById(tipId)
.orElseThrow(() -> new BusinessException(ErrorCode.MARKETING_TIP_NOT_FOUND));
return convertToResponse(marketingTip);
}
private MarketingTipResponse convertToResponse(MarketingTip marketingTip) {
return MarketingTipResponse.builder()
.tipId(marketingTip.getId().getValue())
.storeId(marketingTip.getStoreId())
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.storeLocation(marketingTip.getStoreData().getLocation())
.createdAt(marketingTip.getCreatedAt())
.build();
}
public MarketingTip toDomain() {
WeatherData weatherData = WeatherData.builder()
.temperature(this.weatherTemperature)
.condition(this.weatherCondition)
.humidity(this.weatherHumidity)
.build();
StoreData storeData = StoreData.builder()
.storeName(this.storeName)
.businessType(this.businessType)
.location(this.storeLocation)
.build();
return MarketingTip.builder()
.id(this.id != null ? TipId.of(this.id) : null)
.storeId(this.storeId)
.tipContent(this.tipContent)
.weatherData(weatherData)
.storeData(storeData)
.createdAt(this.createdAt)
.build();
}
}

View File

@ -0,0 +1,32 @@
package com.won.smarketing.recommend.application.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
/**
* 날씨 데이터 서비스 (Mock)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WeatherDataService {
@Cacheable(value = "weatherData", key = "#location")
public com.won.smarketing.recommend.service.MarketingTipService.WeatherInfo getCurrentWeather(String location) {
log.debug("날씨 정보 조회: location={}", location);
// Mock 데이터 반환
double temperature = 20.0 + (Math.random() * 15); // 20-35도
String[] conditions = {"맑음", "흐림", "", "", "안개"};
String condition = conditions[(int) (Math.random() * conditions.length)];
double humidity = 50.0 + (Math.random() * 30); // 50-80%
return com.won.smarketing.recommend.service.MarketingTipService.WeatherInfo.builder()
.temperature(Math.round(temperature * 10) / 10.0)
.condition(condition)
.humidity(Math.round(humidity * 10) / 10.0)
.build();
}
}

View File

@ -2,18 +2,37 @@ 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;
/**
* 마케팅 관련 Use Case 인터페이스
* AI 기반 마케팅 생성 기능 정의
* 마케팅 생성 유즈케이스 인터페이스
* 비즈니스 요구사항을 정의하는 애플리케이션 계층의 인터페이스
*/
public interface MarketingTipUseCase {
/**
* AI 마케팅 생성
*
*
* @param request 마케팅 생성 요청
* @return 생성된 마케팅 응답
* @return 생성된 마케팅 정보
*/
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
}
/**
* 마케팅 이력 조회
*
* @param storeId 매장 ID
* @param pageable 페이징 정보
* @return 마케팅 이력 페이지
*/
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
/**
* 마케팅 상세 조회
*
* @param tipId ID
* @return 마케팅 상세 정보
*/
MarketingTipResponse getMarketingTip(Long tipId);
}

View File

@ -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 캐시 사용
}

View File

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

View File

@ -0,0 +1,33 @@
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 io.netty.handler.timeout.ConnectTimeoutHandler;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
/**
* WebClient 설정
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofMillis(5000))
.doOnConnected(conn -> conn
.addHandlerLast(new ConnectTimeoutHandler(5, TimeUnit.SECONDS)));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
}

View File

@ -0,0 +1,51 @@
package com.won.smarketing.recommend.domain.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
/**
* 비즈니스 인사이트 엔티티
*/
@Entity
@Table(name = "business_insights")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class BusinessInsight {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "insight_id")
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "insight_type", nullable = false, length = 50)
private String insightType;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Column(name = "metric_value")
private Double metricValue;
@Column(name = "recommendation", columnDefinition = "TEXT")
private String recommendation;
@CreatedDate
@Column(name = "created_at")
private LocalDateTime createdAt;
}

View File

@ -1,58 +1,38 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.TipId;
import com.won.smarketing.recommend.domain.model.WeatherData;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 마케팅 도메인 모델
* AI가 생성한 마케팅 팁과 관련 정보를 관리
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTip {
/**
* 마케팅 고유 식별자
*/
private TipId id;
/**
* 매장 ID
*/
private Long storeId;
/**
* AI가 생성한 마케팅 내용
*/
private String tipContent;
/**
* 생성 참고한 날씨 데이터
*/
private WeatherData weatherData;
/**
* 생성 참고한 매장 데이터
*/
private StoreData storeData;
/**
* 생성 시각
*/
private LocalDateTime createdAt;
/**
* 내용 업데이트
*
* @param newContent 새로운 내용
*/
public void updateContent(String newContent) {
if (newContent == null || newContent.trim().isEmpty()) {
throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다.");
}
this.tipContent = newContent.trim();
public static MarketingTip create(Long storeId, String tipContent, WeatherData weatherData, StoreData storeData) {
return MarketingTip.builder()
.storeId(storeId)
.tipContent(tipContent)
.weatherData(weatherData)
.storeData(storeData)
.createdAt(LocalDateTime.now())
.build();
}
}

View File

@ -1,66 +1,19 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 데이터 객체
* 마케팅 생성에 사용되는 매장 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
/**
* 매장명
*/
private String storeName;
/**
* 업종
*/
private String businessType;
/**
* 매장 위치 (주소)
*/
private String location;
/**
* 매장 데이터 유효성 검증
*
* @return 유효성 여부
*/
public boolean isValid() {
return storeName != null && !storeName.trim().isEmpty() &&
businessType != null && !businessType.trim().isEmpty() &&
location != null && !location.trim().isEmpty();
}
/**
* 업종 카테고리 분류
*
* @return 업종 카테고리
*/
public String getBusinessCategory() {
if (businessType == null) {
return "기타";
}
String lowerCaseType = businessType.toLowerCase();
if (lowerCaseType.contains("카페") || lowerCaseType.contains("커피")) {
return "카페";
} else if (lowerCaseType.contains("식당") || lowerCaseType.contains("레스토랑")) {
return "음식점";
} else if (lowerCaseType.contains("베이커리") || lowerCaseType.contains("")) {
return "베이커리";
} else if (lowerCaseType.contains("치킨") || lowerCaseType.contains("피자")) {
return "패스트푸드";
} else {
return "기타";
}
}
}
}

View File

@ -1,29 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 마케팅 식별자 객체
* 마케팅 팁의 고유 식별자를 나타내는 도메인 객체
* ID 객체
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class TipId {
private Long value;
/**
* TipId 생성 팩토리 메서드
*
* @param value 식별자
* @return TipId 인스턴스
*/
public static TipId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("TipId는 양수여야 합니다.");
}
return new TipId(value);
}
}
}

View File

@ -1,66 +1,19 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 날씨 데이터 객체
* 마케팅 생성에 사용되는 날씨 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class WeatherData {
/**
* 온도 (섭씨)
*/
private Double temperature;
/**
* 날씨 상태 (맑음, 흐림, , )
*/
private String condition;
/**
* 습도 (%)
*/
private Double humidity;
/**
* 날씨 데이터 유효성 검증
*
* @return 유효성 여부
*/
public boolean isValid() {
return temperature != null &&
condition != null && !condition.trim().isEmpty() &&
humidity != null && humidity >= 0 && humidity <= 100;
}
/**
* 온도 기반 날씨 상태 설명
*
* @return 날씨 상태 설명
*/
public String getTemperatureDescription() {
if (temperature == null) {
return "알 수 없음";
}
if (temperature >= 30) {
return "매우 더움";
} else if (temperature >= 25) {
return "더움";
} else if (temperature >= 20) {
return "따뜻함";
} else if (temperature >= 10) {
return "선선함";
} else if (temperature >= 0) {
return "춥다";
} else {
return "매우 춥다";
}
}
}
}

View File

@ -0,0 +1,15 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.BusinessInsight;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface BusinessInsightRepository extends JpaRepository<BusinessInsight, Long> {
List<BusinessInsight> findByStoreIdOrderByCreatedAtDesc(Long storeId);
List<BusinessInsight> findByInsightTypeAndStoreId(String insightType, Long storeId);
}

View File

@ -1,56 +1,19 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.TipId;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 마케팅 저장소 인터페이스
* 마케팅 도메인의 데이터 접근 추상화
* 마케팅 레포지토리 인터페이스
*/
public interface MarketingTipRepository {
/**
* 마케팅 저장
*
* @param marketingTip 저장할 마케팅
* @return 저장된 마케팅
*/
MarketingTip save(MarketingTip marketingTip);
/**
* 마케팅 ID로 조회
*
* @param id 마케팅 ID
* @return 마케팅 (Optional)
*/
Optional<MarketingTip> findById(TipId id);
/**
* 매장별 마케팅 목록 조회
*
* @param storeId 매장 ID
* @return 마케팅 목록
*/
List<MarketingTip> findByStoreId(Long storeId);
/**
* 특정 기간 생성된 마케팅 조회
*
* @param storeId 매장 ID
* @param startDate 시작 시각
* @param endDate 종료 시각
* @return 마케팅 목록
*/
List<MarketingTip> findByStoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDate, LocalDateTime endDate);
/**
* 마케팅 삭제
*
* @param id 삭제할 마케팅 ID
*/
void deleteById(TipId id);
}
Optional<MarketingTip> findById(Long tipId);
Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable);
}

View File

@ -7,12 +7,12 @@ import com.won.smarketing.recommend.domain.model.StoreData;
* 외부 매장 서비스로부터 매장 정보 조회 기능 정의
*/
public interface StoreDataProvider {
/**
* 매장 ID로 매장 데이터 조회
*
* 매장 정보 조회
*
* @param storeId 매장 ID
* @return 매장 데이터
*/
StoreData getStoreData(Long storeId);
}
}

View File

@ -7,12 +7,12 @@ import com.won.smarketing.recommend.domain.model.WeatherData;
* 외부 날씨 API로부터 날씨 정보 조회 기능 정의
*/
public interface WeatherDataProvider {
/**
* 특정 위치의 현재 날씨 정보 조회
*
*
* @param location 위치 (주소)
* @return 날씨 데이터
*/
WeatherData getCurrentWeather(String location);
}
}

View File

@ -0,0 +1,137 @@
//import com.won.smarketing.recommend.domain.model.StoreData;
//import com.won.smarketing.recommend.domain.model.WeatherData;
//import com.won.smarketing.recommend.domain.service.AiTipGenerator;
//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.util.Map;
//
///**
// * 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(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
// try {
// log.debug("Python AI 서비스 호출: store={}, weather={}도",
// storeData.getStoreName(), weatherData.getTemperature());
//
// // Python AI 서비스 사용 가능 여부 확인
// if (isPythonServiceAvailable()) {
// return callPythonAiService(storeData, weatherData, additionalRequirement);
// } else {
// log.warn("Python AI 서비스 사용 불가, Fallback 처리");
// return createFallbackTip(storeData, weatherData, additionalRequirement);
// }
//
// } catch (Exception e) {
// log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
// return createFallbackTip(storeData, weatherData, additionalRequirement);
// }
// }
//
// private boolean isPythonServiceAvailable() {
// return !pythonAiServiceApiKey.equals("dummy-key");
// }
//
// private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
// try {
// Map<String, Object> requestData = Map.of(
// "store_name", storeData.getStoreName(),
// "business_type", storeData.getBusinessType(),
// "location", storeData.getLocation(),
// "temperature", weatherData.getTemperature(),
// "weather_condition", weatherData.getCondition(),
// "humidity", weatherData.getHumidity(),
// "additional_requirement", additionalRequirement != null ? additionalRequirement : ""
// );
//
// 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()) {
// return response.getTip();
// }
// } catch (Exception e) {
// log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
// }
//
// return createFallbackTip(storeData, weatherData, additionalRequirement);
// }
//
// private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
// String businessType = storeData.getBusinessType();
// double temperature = weatherData.getTemperature();
// String condition = weatherData.getCondition();
// String storeName = storeData.getStoreName();
//
// // 추가 요청사항이 있는 경우 우선 반영
// if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
// return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!",
// storeName, additionalRequirement);
// }
//
// // 날씨와 업종 기반 규칙
// if (temperature > 25) {
// if (businessType.contains("카페")) {
// return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature);
// } else {
// return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!";
// }
// } else if (temperature < 10) {
// if (businessType.contains("카페")) {
// return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature);
// } else {
// return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!";
// }
// }
//
// if (condition.contains("")) {
// return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!";
// }
//
// // 기본
// return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!",
// storeName, temperature, condition);
// }
//
// 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; }
// }
//}

View File

@ -151,7 +151,7 @@ public class ClaudeAiTipGenerator implements AiTipGenerator {
}
// 업종별 기본
String businessCategory = storeData.getBusinessCategory();
String businessCategory = storeData.getBusinessType();
switch (businessCategory) {
case "카페":
tip.append("인스타그램용 예쁜 음료 사진을 올려보세요.");

View File

@ -0,0 +1,137 @@
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.WeatherData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
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.util.Map;
/**
* 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(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
try {
log.debug("Python AI 서비스 호출: store={}, weather={}도",
storeData.getStoreName(), weatherData.getTemperature());
// Python AI 서비스 사용 가능 여부 확인
if (isPythonServiceAvailable()) {
return callPythonAiService(storeData, weatherData, additionalRequirement);
} else {
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
return createFallbackTip(storeData, weatherData, additionalRequirement);
}
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData, weatherData, additionalRequirement);
}
}
private boolean isPythonServiceAvailable() {
return !pythonAiServiceApiKey.equals("dummy-key");
}
private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
try {
Map<String, Object> requestData = Map.of(
"store_name", storeData.getStoreName(),
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(),
"temperature", weatherData.getTemperature(),
"weather_condition", weatherData.getCondition(),
"humidity", weatherData.getHumidity(),
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
);
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()) {
return response.getTip();
}
} catch (Exception e) {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeData, weatherData, additionalRequirement);
}
private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
String businessType = storeData.getBusinessType();
double temperature = weatherData.getTemperature();
String condition = weatherData.getCondition();
String storeName = storeData.getStoreName();
// 추가 요청사항이 있는 경우 우선 반영
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!",
storeName, additionalRequirement);
}
// 날씨와 업종 기반 규칙
if (temperature > 25) {
if (businessType.contains("카페")) {
return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature);
} else {
return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!";
}
} else if (temperature < 10) {
if (businessType.contains("카페")) {
return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature);
} else {
return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!";
}
}
if (condition.contains("")) {
return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!";
}
// 기본
return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!",
storeName, temperature, condition);
}
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; }
}
}

View File

@ -7,16 +7,15 @@ import com.won.smarketing.recommend.domain.service.StoreDataProvider;
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.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
/**
* 매장 API 데이터 제공자 구현체
* 외부 매장 서비스 API를 통해 매장 정보 조회
*/
@Slf4j
@Service
@ -28,83 +27,98 @@ public class StoreApiDataProvider implements StoreDataProvider {
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
/**
* 매장 ID로 매장 데이터 조회
*
* @param storeId 매장 ID
* @return 매장 데이터
*/
@Value("${external.store-service.timeout}")
private int timeout;
@Override
@Cacheable(value = "storeData", key = "#storeId")
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시작: storeId={}", storeId);
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store?storeId=" + storeId)
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofSeconds(10))
.block();
log.debug("매장 정보 조회 시도: storeId={}", storeId);
if (response == null || response.getData() == null) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
// 외부 서비스 연결 시도, 실패 Mock 데이터 반환
if (isStoreServiceAvailable()) {
return callStoreService(storeId);
} else {
log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId);
return createMockStoreData(storeId);
}
StoreApiData storeApiData = response.getData();
StoreData storeData = StoreData.builder()
.storeName(storeApiData.getStoreName())
.businessType(storeApiData.getBusinessType())
.location(storeApiData.getAddress())
.build();
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
return storeData;
} catch (WebClientResponseException e) {
log.error("매장 서비스 API 호출 실패: storeId={}, status={}", storeId, e.getStatusCode(), e);
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
} catch (Exception e) {
log.error("매장 정보 조회 중 오류 발생: storeId={}", storeId, e);
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockStoreData(storeId);
}
}
/**
* 매장 API 응답 DTO
*/
private boolean isStoreServiceAvailable() {
return !storeServiceBaseUrl.equals("http://localhost:8082");
}
private StoreData callStoreService(Long storeId) {
try {
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store/" + storeId)
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder()
.storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress())
.build();
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
log.error("매장 서비스 호출 실패: {}", e.getMessage());
}
return createMockStoreData(storeId);
}
private StoreData createMockStoreData(Long storeId) {
return StoreData.builder()
.storeName("테스트 카페 " + storeId)
.businessType("카페")
.location("서울시 강남구")
.build();
}
private static class StoreApiResponse {
private int status;
private String message;
private StoreApiData data;
private StoreInfo data;
// Getters and Setters
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 StoreApiData getData() { return data; }
public void setData(StoreApiData data) { this.data = data; }
}
public StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; }
/**
* 매장 API 데이터 DTO
*/
private static class StoreApiData {
private Long storeId;
private String storeName;
private String businessType;
private String address;
static class StoreInfo {
private Long storeId;
private String storeName;
private String businessType;
private String address;
private String phoneNumber;
// Getters and Setters
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 Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getStoreName() { return storeName; }
public void setStoreName(String storeName) { this.storeName = storeName; }
public String getBusinessType() { return businessType; }
public void setBusinessType(String businessType) { this.businessType = businessType; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getPhoneNumber() { return phoneNumber; }
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
}
}
}
}

View File

@ -1,22 +1,18 @@
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.WeatherData;
import com.won.smarketing.recommend.domain.service.WeatherDataProvider;
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.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
/**
* 날씨 API 데이터 제공자 구현체
* 외부 날씨 API를 통해 날씨 정보 조회
*/
@Slf4j
@Service
@ -28,128 +24,45 @@ public class WeatherApiDataProvider implements WeatherDataProvider {
@Value("${external.weather-api.api-key}")
private String weatherApiKey;
@Value("${external.weather-api.base-url}")
private String weatherApiBaseUrl;
@Value("${external.weather-api.timeout}")
private int timeout;
/**
* 특정 위치의 현재 날씨 정보 조회
*
* @param location 위치 (주소)
* @return 날씨 데이터
*/
@Override
public WeatherApiResponse getCurrentWeather(String location) {
@Cacheable(value = "weatherData", key = "#location")
public WeatherData getCurrentWeather(String location) {
try {
log.debug("날씨 정보 조회 시작: location={}", location);
// 한국 주요 도시로 단순화
String city = extractCity(location);
WeatherApiResponse response = webClient
.get()
.uri(uriBuilder -> uriBuilder
.scheme("https")
.host("api.openweathermap.org")
.path("/data/2.5/weather")
.queryParam("q", city + ",KR")
.queryParam("appid", weatherApiKey)
.queryParam("units", "metric")
.queryParam("lang", "kr")
.build())
.retrieve()
.bodyToMono(WeatherApiResponse.class)
.timeout(Duration.ofSeconds(10))
.onErrorReturn(createDefaultWeatherData()) // 오류 기본값 반환
.block();
log.debug("날씨 정보 조회: location={}", location);
if (response == null) {
return createDefaultWeatherData();
// 개발 환경에서는 Mock 데이터 반환
if (weatherApiKey.equals("dummy-key")) {
return createMockWeatherData(location);
}
WeatherData weatherData = WeatherData.builder()
.temperature(response.getMain().getTemp())
.condition(response.getWeather()[0].getDescription())
.humidity(response.getMain().getHumidity())
.build();
log.debug("날씨 정보 조회 완료: {}도, {}", weatherData.getTemperature(), weatherData.getCondition());
return weatherData;
// 실제 날씨 API 호출 (향후 구현)
return callWeatherApi(location);
} catch (Exception e) {
log.warn("날씨 정보 조회 실패, 기본값 사용: location={}", location, e);
return createDefaultWeatherData();
log.warn("날씨 정보 조회 실패, Mock 데이터 사용: location={}", location, e);
return createMockWeatherData(location);
}
}
/**
* 주소에서 도시명 추출
*
* @param location 전체 주소
* @return 도시명
*/
private String extractCity(String location) {
if (location == null || location.trim().isEmpty()) {
return "Seoul";
}
// 서울, 부산, 대구, 인천, 광주, 대전, 울산 주요 도시 추출
String[] cities = {"서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "수원", "창원"};
for (String city : cities) {
if (location.contains(city)) {
return city;
}
}
return "Seoul"; // 기본값
private WeatherData callWeatherApi(String location) {
// 실제 OpenWeatherMap API 호출 로직 (향후 구현)
log.info("실제 날씨 API 호출: {}", location);
return createMockWeatherData(location);
}
/**
* 기본 날씨 데이터 생성 (API 호출 실패 사용)
*
* @return 기본 날씨 데이터
*/
private WeatherApiResponse createDefaultWeatherData() {
WeatherApiResponse response = new WeatherApiResponse();
response.setMain(new WeatherApiResponse.Main());
response.getMain().setTemp(20.0); // 기본 온도 20도
response.getMain().setHumidity(60.0); // 기본 습도 60%
WeatherApiResponse.Weather[] weather = new WeatherApiResponse.Weather[1];
weather[0] = new WeatherApiResponse.Weather();
weather[0].setDescription("맑음");
response.setWeather(weather);
return response;
private WeatherData createMockWeatherData(String location) {
double temperature = 20.0 + (Math.random() * 15); // 20-35도
String[] conditions = {"맑음", "흐림", "", "", "안개"};
String condition = conditions[(int) (Math.random() * conditions.length)];
double humidity = 50.0 + (Math.random() * 30); // 50-80%
return WeatherData.builder()
.temperature(Math.round(temperature * 10) / 10.0)
.condition(condition)
.humidity(Math.round(humidity * 10) / 10.0)
.build();
}
/**
* 날씨 API 응답 DTO
*/
private static class WeatherApiResponse {
private Main main;
private Weather[] weather;
public Main getMain() { return main; }
public void setMain(Main main) { this.main = main; }
public Weather[] getWeather() { return weather; }
public void setWeather(Weather[] weather) { this.weather = weather; }
static class Main {
private Double temp;
private Double humidity;
public Double getTemp() { return temp; }
public void setTemp(Double temp) { this.temp = temp; }
public Double getHumidity() { return humidity; }
public void setHumidity(Double humidity) { this.humidity = humidity; }
}
static class Weather {
private String description;
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
}
}
}

View File

@ -0,0 +1,38 @@
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.infrastructure.persistence.MarketingTipJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* JPA 마케팅 레포지토리 구현체
*/
@Repository
@RequiredArgsConstructor
public class JpaMarketingTipRepository implements MarketingTipRepository {
private final MarketingTipJpaRepository jpaRepository;
@Override
public MarketingTip save(MarketingTip marketingTip) {
com.won.smarketing.recommend.entity.MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
com.won.smarketing.recommend.entity.MarketingTipEntity savedEntity = jpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId)
.map(MarketingTipEntity::toDomain);
}
@Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable)
.map(MarketingTipEntity::toDomain);
}
}

View File

@ -0,0 +1,58 @@
package com.won.smarketing.recommend.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
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)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "tip_content", columnDefinition = "TEXT", nullable = false)
private String tipContent;
// WeatherData 임베디드
@Column(name = "weather_temperature")
private Double weatherTemperature;
@Column(name = "weather_condition", length = 100)
private String weatherCondition;
@Column(name = "weather_humidity")
private Double weatherHumidity;
// StoreData 임베디드
@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;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,14 @@
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;
/**
* 마케팅 JPA 레포지토리
*/
public interface MarketingTipJpaRepository extends JpaRepository<com.won.smarketing.recommend.entity.MarketingTipEntity, Long> {
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
Page<com.won.smarketing.recommend.entity.MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
}

View File

@ -5,35 +5,73 @@ 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 마케팅 추천을 위한 REST API 컨트롤러
* AI 기반 마케팅 생성 기능 제공
* AI 마케팅 추천 컨트롤러
*/
@Tag(name = "AI 마케팅 추천", description = "AI 기반 맞춤형 마케팅 추천 API")
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j
@RestController
@RequestMapping("/api/recommendation")
@RequestMapping("/api/recommendations")
@RequiredArgsConstructor
public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
/**
* AI 마케팅 생성
*
* @param request 마케팅 생성 요청
* @return 생성된 마케팅
*/
@Operation(summary = "AI 마케팅 팁 생성", description = "매장 특성과 환경 정보를 바탕으로 AI 마케팅 팁을 생성합니다.")
@Operation(
summary = "AI 마케팅 팁 생성",
description = "매장 정보와 환경 데이터를 기반으로 AI 마케팅 팁을 생성합니다."
)
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(@Valid @RequestBody MarketingTipRequest request) {
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
@Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) {
log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId());
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
return ResponseEntity.ok(ApiResponse.success(response, "AI 마케팅 팁이 성공적으로 생성되었습니다."));
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);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,24 @@
package com.won.smarketing.recommend.presentation.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* Python AI 서비스 요청 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AIServiceRequest {
private String serviceType; // "marketing_tips", "business_insights", "trend_analysis"
private Long storeId;
private String category;
private Map<String, Object> parameters;
private Map<String, Object> context; // 매장 정보, 과거 데이터
}

View File

@ -1,24 +1,26 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* AI 마케팅 생성 요청 DTO
* 매장 정보를 기반으로 개인화된 마케팅 팁을 요청할 사용됩니다.
*/
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@Schema(description = "마케팅 팁 생성 요청")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "AI 마케팅 팁 생성 요청")
public class MarketingTipRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
@Positive(message = "매장 ID는 양수여야 합니다")
@Schema(description = "매장 ID", example = "1", required = true)
private Long storeId;
}
@Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요")
private String additionalRequirement;
}

View File

@ -8,24 +8,61 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* AI 마케팅 생성 응답 DTO
* AI가 생성한 개인화된 마케팅 정보를 전달합니다.
*/
@Schema(description = "마케팅 팁 응답")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "AI 마케팅 팁 생성 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)",
example = "오늘 같은 비 오는 날에는 따뜻한 음료와 함께 실내 분위기를 강조한 포스팅을 올려보세요. #비오는날카페 #따뜻한음료 해시태그로 감성을 어필해보세요!")
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "카페 봄날")
private String storeName;
@Schema(description = "AI 생성 마케팅 팁 내용")
private String tipContent;
@Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00")
@Schema(description = "날씨 정보")
private WeatherInfo weatherInfo;
@Schema(description = "매장 정보")
private StoreInfo storeInfo;
@Schema(description = "생성 일시")
private LocalDateTime createdAt;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class WeatherInfo {
@Schema(description = "온도", example = "25.5")
private Double temperature;
@Schema(description = "날씨 상태", example = "맑음")
private String condition;
@Schema(description = "습도", example = "60.0")
private Double humidity;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class StoreInfo {
@Schema(description = "매장명", example = "카페 봄날")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "위치", example = "서울시 강남구")
private String location;
}
}

View File

@ -7,7 +7,7 @@ spring:
application:
name: ai-recommend-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommenddb}
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
jpa:
@ -18,6 +18,11 @@ spring:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
ai:
service:
@ -47,4 +52,8 @@ springdoc:
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}