diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java index 2c12c85..c331ea3 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -4,12 +4,14 @@ 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; -/** - * AI 추천 서비스 메인 애플리케이션 - */ -@SpringBootApplication +@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) { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java deleted file mode 100644 index 30338a0..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/AiApiService.java +++ /dev/null @@ -1,21 +0,0 @@ -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); -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index 67193b9..f54dc92 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -5,11 +5,8 @@ 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.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.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; @@ -32,42 +29,36 @@ public class MarketingTipService implements MarketingTipUseCase { private final MarketingTipRepository marketingTipRepository; private final StoreDataProvider storeDataProvider; - private final WeatherDataProvider weatherDataProvider; private final AiTipGenerator aiTipGenerator; @Override 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(), weatherData.getCondition()); - - // 3. AI 팁 생성 - String aiGeneratedTip = aiTipGenerator.generateTip(storeData, weatherData, request.getAdditionalRequirement()); + + // 2. Python AI 서비스로 팁 생성 (매장 정보 + 추가 요청사항 전달) + String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement()); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); - - // 4. 도메인 객체 생성 및 저장 + + // 3. 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(request.getStoreId()) .tipContent(aiGeneratedTip) - .weatherData(weatherData) .storeData(storeData) .build(); - + MarketingTip savedTip = marketingTipRepository.save(marketingTip); log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue()); - + return convertToResponse(savedTip); - + } catch (Exception e) { log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e); - throw new BusinessException(ErrorCode.AI_TIP_GENERATION_FAILED); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); } } @@ -76,9 +67,9 @@ public class MarketingTipService implements MarketingTipUseCase { @Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize") public Page getMarketingTipHistory(Long storeId, Pageable pageable) { log.info("마케팅 팁 이력 조회: storeId={}", storeId); - + Page tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable); - + return tips.map(this::convertToResponse); } @@ -86,10 +77,10 @@ public class MarketingTipService implements MarketingTipUseCase { @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)); - + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR)); + return convertToResponse(marketingTip); } @@ -98,32 +89,13 @@ public class MarketingTipService implements MarketingTipUseCase { .tipId(marketingTip.getId().getValue()) .storeId(marketingTip.getStoreId()) .storeName(marketingTip.getStoreData().getStoreName()) - .businessType(marketingTip.getStoreData().getBusinessType()) - .storeLocation(marketingTip.getStoreData().getLocation()) + .tipContent(marketingTip.getTipContent()) + .storeInfo(MarketingTipResponse.StoreInfo.builder() + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .location(marketingTip.getStoreData().getLocation()) + .build()) .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(); - } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java deleted file mode 100644 index 164a1a9..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/WeatherDataService.java +++ /dev/null @@ -1,32 +0,0 @@ -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(); - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java index b1a8329..48bd991 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -6,33 +6,22 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; /** - * 마케팅 팁 생성 유즈케이스 인터페이스 - * 비즈니스 요구사항을 정의하는 애플리케이션 계층의 인터페이스 + * 마케팅 팁 유즈케이스 인터페이스 */ public interface MarketingTipUseCase { - + /** * AI 마케팅 팁 생성 - * - * @param request 마케팅 팁 생성 요청 - * @return 생성된 마케팅 팁 정보 */ MarketingTipResponse generateMarketingTips(MarketingTipRequest request); - + /** * 마케팅 팁 이력 조회 - * - * @param storeId 매장 ID - * @param pageable 페이징 정보 - * @return 마케팅 팁 이력 페이지 */ Page getMarketingTipHistory(Long storeId, Pageable pageable); - + /** * 마케팅 팁 상세 조회 - * - * @param tipId 팁 ID - * @return 마케팅 팁 상세 정보 */ MarketingTipResponse getMarketingTip(Long tipId); -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java index 9aec563..8dec201 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java @@ -10,4 +10,4 @@ import org.springframework.context.annotation.Configuration; @EnableCaching public class CacheConfig { // 기본 Simple 캐시 사용 -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java index 47ed442..53578a1 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java @@ -4,15 +4,13 @@ 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 설정 + * WebClient 설정 (간소화된 버전) */ @Configuration public class WebClientConfig { @@ -21,9 +19,7 @@ public class WebClientConfig { 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))); + .responseTimeout(Duration.ofMillis(5000)); return WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java deleted file mode 100644 index 5022134..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/BusinessInsight.java +++ /dev/null @@ -1,51 +0,0 @@ -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; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java index 48bc27b..302a79f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -1,8 +1,5 @@ package com.won.smarketing.recommend.domain.model; -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; @@ -11,28 +8,26 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * 마케팅 팁 도메인 모델 + * 마케팅 팁 도메인 모델 (날씨 정보 제거) */ @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class MarketingTip { - + private TipId id; private Long storeId; private String tipContent; - private WeatherData weatherData; private StoreData storeData; private LocalDateTime createdAt; - - public static MarketingTip create(Long storeId, String tipContent, WeatherData weatherData, StoreData storeData) { + + public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) { return MarketingTip.builder() .storeId(storeId) .tipContent(tipContent) - .weatherData(weatherData) .storeData(storeData) .createdAt(LocalDateTime.now()) .build(); } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java index 2afae1b..87c395d 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -16,4 +16,4 @@ public class StoreData { private String storeName; private String businessType; private String location; -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java index 105b3af..47808cb 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -14,8 +14,8 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class TipId { private Long value; - + public static TipId of(Long value) { return new TipId(value); } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java deleted file mode 100644 index 90c6455..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java +++ /dev/null @@ -1,19 +0,0 @@ -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 WeatherData { - private Double temperature; - private String condition; - private Double humidity; -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java deleted file mode 100644 index 9925144..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/BusinessInsightRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -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 { - - List findByStoreIdOrderByCreatedAtDesc(Long storeId); - - List findByInsightTypeAndStoreId(String insightType, Long storeId); -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java index 140dff3..ce0be77 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -7,7 +7,7 @@ import org.springframework.data.domain.Pageable; import java.util.Optional; /** - * 마케팅 팁 레포지토리 인터페이스 + * 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스) */ public interface MarketingTipRepository { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java index 8680caa..19547c0 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java @@ -1,20 +1,18 @@ package com.won.smarketing.recommend.domain.service; import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.WeatherData; /** - * AI 팁 생성 도메인 서비스 인터페이스 - * AI를 활용한 마케팅 팁 생성 기능 정의 + * AI 팁 생성 도메인 서비스 인터페이스 (단순화) */ public interface AiTipGenerator { /** - * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 + * Python AI 서비스를 통한 마케팅 팁 생성 * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 + * @param storeData 매장 정보 + * @param additionalRequirement 추가 요청사항 * @return AI가 생성한 마케팅 팁 */ - String generateTip(StoreData storeData, WeatherData weatherData); + String generateTip(StoreData storeData, String additionalRequirement); } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java index aa526b1..1cea568 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -4,15 +4,8 @@ import com.won.smarketing.recommend.domain.model.StoreData; /** * 매장 데이터 제공 도메인 서비스 인터페이스 - * 외부 매장 서비스로부터 매장 정보 조회 기능 정의 */ public interface StoreDataProvider { - - /** - * 매장 정보 조회 - * - * @param storeId 매장 ID - * @return 매장 데이터 - */ + StoreData getStoreData(Long storeId); -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java deleted file mode 100644 index 6f31ae0..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.won.smarketing.recommend.domain.service; - -import com.won.smarketing.recommend.domain.model.WeatherData; - -/** - * 날씨 데이터 제공 도메인 서비스 인터페이스 - * 외부 날씨 API로부터 날씨 정보 조회 기능 정의 - */ -public interface WeatherDataProvider { - - /** - * 특정 위치의 현재 날씨 정보 조회 - * - * @param location 위치 (주소) - * @return 날씨 데이터 - */ - WeatherData getCurrentWeather(String location); -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java deleted file mode 100644 index b5fbed3..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/AiApiServiceImpl.java +++ /dev/null @@ -1,137 +0,0 @@ -//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 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; } -// } -//} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java deleted file mode 100644 index 827ef54..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java +++ /dev/null @@ -1,190 +0,0 @@ -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.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.http.HttpHeaders; -import org.springframework.http.MediaType; -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; -import java.util.Map; - -/** - * Claude AI 팁 생성기 구현체 - * Claude AI API를 통해 마케팅 팁 생성 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class ClaudeAiTipGenerator implements AiTipGenerator { - - private final WebClient webClient; - - @Value("${external.claude-ai.api-key}") - private String claudeApiKey; - - @Value("${external.claude-ai.base-url}") - private String claudeApiBaseUrl; - - @Value("${external.claude-ai.model}") - private String claudeModel; - - @Value("${external.claude-ai.max-tokens}") - private Integer maxTokens; - - /** - * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return AI가 생성한 마케팅 팁 - */ - @Override - public String generateTip(StoreData storeData, WeatherData weatherData) { - try { - log.debug("AI 마케팅 팁 생성 시작: store={}, weather={}도", - storeData.getStoreName(), weatherData.getTemperature()); - - String prompt = buildPrompt(storeData, weatherData); - - Map requestBody = Map.of( - "model", claudeModel, - "max_tokens", maxTokens, - "messages", new Object[]{ - Map.of( - "role", "user", - "content", prompt - ) - } - ); - - ClaudeApiResponse response = webClient - .post() - .uri(claudeApiBaseUrl + "/v1/messages") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + claudeApiKey) - .header("anthropic-version", "2023-06-01") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(ClaudeApiResponse.class) - .timeout(Duration.ofSeconds(30)) - .block(); - - if (response == null || response.getContent() == null || response.getContent().length == 0) { - throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); - } - - String generatedTip = response.getContent()[0].getText(); - - // 100자 제한 적용 - if (generatedTip.length() > 100) { - generatedTip = generatedTip.substring(0, 97) + "..."; - } - - log.debug("AI 마케팅 팁 생성 완료: length={}", generatedTip.length()); - return generatedTip; - - } catch (WebClientResponseException e) { - log.error("Claude AI API 호출 실패: status={}", e.getStatusCode(), e); - return generateFallbackTip(storeData, weatherData); - } catch (Exception e) { - log.error("AI 마케팅 팁 생성 중 오류 발생", e); - return generateFallbackTip(storeData, weatherData); - } - } - - /** - * AI 프롬프트 구성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return 프롬프트 문자열 - */ - private String buildPrompt(StoreData storeData, WeatherData weatherData) { - return String.format( - "다음 매장을 위한 오늘의 마케팅 팁을 100자 이내로 작성해주세요.\n\n" + - "매장 정보:\n" + - "- 매장명: %s\n" + - "- 업종: %s\n" + - "- 위치: %s\n\n" + - "오늘 날씨:\n" + - "- 온도: %.1f도\n" + - "- 날씨: %s\n" + - "- 습도: %.1f%%\n\n" + - "날씨와 매장 특성을 고려한 실용적이고 구체적인 마케팅 팁을 제안해주세요. " + - "반드시 100자 이내로 작성하고, 친근하고 실행 가능한 조언을 해주세요.", - storeData.getStoreName(), - storeData.getBusinessType(), - storeData.getLocation(), - weatherData.getTemperature(), - weatherData.getCondition(), - weatherData.getHumidity() - ); - } - - /** - * AI API 실패 시 대체 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return 대체 마케팅 팁 - */ - private String generateFallbackTip(StoreData storeData, WeatherData weatherData) { - StringBuilder tip = new StringBuilder(); - - // 날씨 기반 기본 팁 - if (weatherData.getTemperature() >= 25) { - tip.append("더운 날씨에는 시원한 음료나 디저트를 홍보해보세요! "); - } else if (weatherData.getTemperature() <= 10) { - tip.append("추운 날씨에는 따뜻한 메뉴를 강조해보세요! "); - } else { - tip.append("좋은 날씨를 활용한 야외석 이용을 추천해보세요! "); - } - - // 업종별 기본 팁 - String businessCategory = storeData.getBusinessType(); - switch (businessCategory) { - case "카페": - tip.append("인스타그램용 예쁜 음료 사진을 올려보세요."); - break; - case "음식점": - tip.append("시그니처 메뉴의 맛있는 사진을 SNS에 공유해보세요."); - break; - default: - tip.append("오늘의 특별 메뉴를 SNS에 홍보해보세요."); - break; - } - - String fallbackTip = tip.toString(); - return fallbackTip.length() > 100 ? fallbackTip.substring(0, 97) + "..." : fallbackTip; - } - - /** - * Claude API 응답 DTO - */ - private static class ClaudeApiResponse { - private Content[] content; - - public Content[] getContent() { return content; } - public void setContent(Content[] content) { this.content = content; } - - static class Content { - private String text; - private String type; - - public String getText() { return text; } - public void setText(String text) { this.text = text; } - public String getType() { return type; } - public void setType(String type) { this.type = type; } - } - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java index 44a5f06..4356fa9 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -1,20 +1,21 @@ +package com.won.smarketing.recommend.infrastructure.external; + 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.stereotype.Service; // 이 어노테이션이 누락되어 있었음 import org.springframework.web.reactive.function.client.WebClient; import java.time.Duration; import java.util.Map; /** - * Python AI 팁 생성 구현체 + * Python AI 팁 생성 구현체 (날씨 정보 제거) */ @Slf4j -@Service +@Service // 추가된 어노테이션 @RequiredArgsConstructor public class PythonAiTipGenerator implements AiTipGenerator { @@ -30,22 +31,21 @@ public class PythonAiTipGenerator implements AiTipGenerator { private int timeout; @Override - public String generateTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + public String generateTip(StoreData storeData, String additionalRequirement) { try { - log.debug("Python AI 서비스 호출: store={}, weather={}도", - storeData.getStoreName(), weatherData.getTemperature()); + log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName()); // Python AI 서비스 사용 가능 여부 확인 if (isPythonServiceAvailable()) { - return callPythonAiService(storeData, weatherData, additionalRequirement); + return callPythonAiService(storeData, additionalRequirement); } else { log.warn("Python AI 서비스 사용 불가, Fallback 처리"); - return createFallbackTip(storeData, weatherData, additionalRequirement); + return createFallbackTip(storeData, additionalRequirement); } } catch (Exception e) { log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); - return createFallbackTip(storeData, weatherData, additionalRequirement); + return createFallbackTip(storeData, additionalRequirement); } } @@ -53,18 +53,18 @@ public class PythonAiTipGenerator implements AiTipGenerator { return !pythonAiServiceApiKey.equals("dummy-key"); } - private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + private String callPythonAiService(StoreData storeData, String additionalRequirement) { try { + // Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달) Map 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 : "" ); + log.debug("Python AI 서비스 요청 데이터: {}", requestData); + PythonAiResponse response = webClient .post() .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") @@ -77,49 +77,50 @@ public class PythonAiTipGenerator implements AiTipGenerator { .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(storeData, weatherData, additionalRequirement); + return createFallbackTip(storeData, additionalRequirement); } - private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) { + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreData storeData, String additionalRequirement) { String businessType = storeData.getBusinessType(); - double temperature = weatherData.getTemperature(); - String condition = weatherData.getCondition(); String storeName = storeData.getStoreName(); + String location = storeData.getLocation(); // 추가 요청사항이 있는 경우 우선 반영 if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) { - return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!", + 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 (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 (condition.contains("비")) { - return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!"; + // 지역별 팁 + 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에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!", - storeName, temperature, condition); + return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName); } private static class PythonAiResponse { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java index ac84ee4..c35a9e7 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -8,7 +8,7 @@ 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.stereotype.Service; // 이 어노테이션이 누락되어 있었음 import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; @@ -18,7 +18,7 @@ import java.time.Duration; * 매장 API 데이터 제공자 구현체 */ @Slf4j -@Service +@Service // 추가된 어노테이션 @RequiredArgsConstructor public class StoreApiDataProvider implements StoreDataProvider { diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java deleted file mode 100644 index 8bf4d7c..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.won.smarketing.recommend.infrastructure.external; - -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 java.time.Duration; - -/** - * 날씨 API 데이터 제공자 구현체 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class WeatherApiDataProvider implements WeatherDataProvider { - - private final WebClient webClient; - - @Value("${external.weather-api.api-key}") - private String weatherApiKey; - - @Value("${external.weather-api.timeout}") - private int timeout; - - @Override - @Cacheable(value = "weatherData", key = "#location") - public WeatherData getCurrentWeather(String location) { - try { - log.debug("날씨 정보 조회: location={}", location); - - // 개발 환경에서는 Mock 데이터 반환 - if (weatherApiKey.equals("dummy-key")) { - return createMockWeatherData(location); - } - - // 실제 날씨 API 호출 (향후 구현) - return callWeatherApi(location); - - } catch (Exception e) { - log.warn("날씨 정보 조회 실패, Mock 데이터 사용: location={}", location, e); - return createMockWeatherData(location); - } - } - - private WeatherData callWeatherApi(String location) { - // 실제 OpenWeatherMap API 호출 로직 (향후 구현) - log.info("실제 날씨 API 호출: {}", location); - return createMockWeatherData(location); - } - - 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(); - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java index 7d47714..1bccd9f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java @@ -1,5 +1,8 @@ -package com.won.smarketing.recommend.entity; +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; @@ -11,7 +14,7 @@ import jakarta.persistence.*; import java.time.LocalDateTime; /** - * 마케팅 팁 JPA 엔티티 + * 마케팅 팁 JPA 엔티티 (날씨 정보 제거) */ @Entity @Table(name = "marketing_tips") @@ -29,20 +32,10 @@ public class MarketingTipEntity { @Column(name = "store_id", nullable = false) private Long storeId; - @Column(name = "tip_content", columnDefinition = "TEXT", nullable = false) + @Column(name = "tip_content", nullable = false, length = 2000) 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; @@ -55,4 +48,32 @@ public class MarketingTipEntity { @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; -} \ No newline at end of file + + public static MarketingTipEntity fromDomain(MarketingTip marketingTip) { + return MarketingTipEntity.builder() + .id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null) + .storeId(marketingTip.getStoreId()) + .tipContent(marketingTip.getTipContent()) + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .storeLocation(marketingTip.getStoreData().getLocation()) + .createdAt(marketingTip.getCreatedAt()) + .build(); + } + + public MarketingTip toDomain() { + 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) + .storeData(storeData) + .createdAt(this.createdAt) + .build(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java index ca1ec19..e2a9d0d 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java @@ -1,14 +1,18 @@ +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; /** * 마케팅 팁 JPA 레포지토리 */ -public interface MarketingTipJpaRepository extends JpaRepository { - +@Repository +public interface MarketingTipJpaRepository extends JpaRepository { + @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") - Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); -} \ No newline at end of file + Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java similarity index 69% rename from smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java index 45d7218..6b8198f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/JpaMarketingTipRepository.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java @@ -1,6 +1,7 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + 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; @@ -9,18 +10,18 @@ import org.springframework.stereotype.Repository; import java.util.Optional; /** - * JPA 마케팅 팁 레포지토리 구현체 + * 마케팅 팁 레포지토리 구현체 */ @Repository @RequiredArgsConstructor -public class JpaMarketingTipRepository implements MarketingTipRepository { +public class MarketingTipRepositoryImpl 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); + MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip); + MarketingTipEntity savedEntity = jpaRepository.save(entity); return savedEntity.toDomain(); } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java new file mode 100644 index 0000000..ad30482 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java @@ -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 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); \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java index fbbab48..89912d3 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -29,49 +29,49 @@ public class RecommendationController { private final MarketingTipUseCase marketingTipUseCase; @Operation( - summary = "AI 마케팅 팁 생성", - description = "매장 정보와 환경 데이터를 기반으로 AI 마케팅 팁을 생성합니다." + summary = "AI 마케팅 팁 생성", + description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다." ) @PostMapping("/marketing-tips") public ResponseEntity> generateMarketingTips( @Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) { - + log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId()); - + MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); - + log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId()); return ResponseEntity.ok(ApiResponse.success(response)); } @Operation( - summary = "마케팅 팁 이력 조회", - description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다." + summary = "마케팅 팁 이력 조회", + description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다." ) @GetMapping("/marketing-tips") public ResponseEntity>> getMarketingTipHistory( @Parameter(description = "매장 ID") @RequestParam Long storeId, Pageable pageable) { - + log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber()); - + Page response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable); - + return ResponseEntity.ok(ApiResponse.success(response)); } @Operation( - summary = "마케팅 팁 상세 조회", - description = "특정 마케팅 팁의 상세 정보를 조회합니다." + summary = "마케팅 팁 상세 조회", + description = "특정 마케팅 팁의 상세 정보를 조회합니다." ) @GetMapping("/marketing-tips/{tipId}") public ResponseEntity> getMarketingTip( @Parameter(description = "팁 ID") @PathVariable Long tipId) { - + log.info("마케팅 팁 상세 조회: tipId={}", tipId); - + MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId); - + return ResponseEntity.ok(ApiResponse.success(response)); } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java deleted file mode 100644 index 396e20c..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/AIServiceRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -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 parameters; - private Map context; // 매장 정보, 과거 데이터 등 -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java deleted file mode 100644 index 9e90fc8..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * 상세 AI 마케팅 팁 응답 DTO - * AI 마케팅 팁과 함께 생성 시 사용된 환경 데이터도 포함합니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "상세 AI 마케팅 팁 응답") -public class DetailedMarketingTipResponse { - - @Schema(description = "팁 ID", example = "1") - private Long tipId; - - @Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)") - private String tipContent; - - @Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00") - private LocalDateTime createdAt; - - @Schema(description = "팁 생성 시 참고된 날씨 정보") - private WeatherInfoDto weatherInfo; - - @Schema(description = "팁 생성 시 참고된 매장 정보") - private StoreInfoDto storeInfo; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java deleted file mode 100644 index 43c77da..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * 에러 응답 DTO - * AI 추천 서비스에서 발생하는 에러 정보를 전달합니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "에러 응답") -public class ErrorResponseDto { - - @Schema(description = "에러 코드", example = "AI_SERVICE_ERROR") - private String errorCode; - - @Schema(description = "에러 메시지", example = "AI 서비스 연결에 실패했습니다") - private String message; - - @Schema(description = "에러 발생 시간", example = "2024-01-15T10:30:00") - private LocalDateTime timestamp; - - @Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips") - private String path; -} - diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java deleted file mode 100644 index 70de05e..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -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.Data; -import lombok.NoArgsConstructor; - -/** - * AI 마케팅 팁 생성을 위한 내부 요청 DTO - * 애플리케이션 계층에서 AI 서비스 호출 시 사용됩니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "AI 마케팅 팁 생성 내부 요청") -public class MarketingTipGenerationRequest { - - @NotNull(message = "매장 정보는 필수입니다") - @Schema(description = "매장 정보", required = true) - private StoreInfoDto storeInfo; - - @Schema(description = "현재 날씨 정보") - private WeatherInfoDto weatherInfo; - - @Schema(description = "팁 생성 옵션", example = "일반") - private String tipType; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java index c706619..5a0ceb5 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java @@ -23,4 +23,4 @@ public class MarketingTipRequest { @Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요") private String additionalRequirement; -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java index 047f34b..6c7ac7f 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -27,30 +27,12 @@ public class MarketingTipResponse { @Schema(description = "AI 생성 마케팅 팁 내용") private String tipContent; - @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 @@ -65,4 +47,4 @@ public class MarketingTipResponse { @Schema(description = "위치", example = "서울시 강남구") private String location; } -} \ No newline at end of file +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java deleted file mode 100644 index aae7983..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 매장 정보 DTO - * AI 마케팅 팁 생성 시 매장 특성을 반영하기 위한 정보입니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "매장 정보") -public class StoreInfoDto { - - @Schema(description = "매장명", example = "카페 원더풀") - private String storeName; - - @Schema(description = "업종", example = "카페") - private String businessType; - - @Schema(description = "매장 위치", example = "서울시 강남구") - private String location; -} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java deleted file mode 100644 index 9757f11..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 날씨 정보 DTO - * AI 마케팅 팁 생성 시 참고되는 환경 데이터입니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "날씨 정보") -public class WeatherInfoDto { - - @Schema(description = "기온 (섭씨)", example = "23.5") - private Double temperature; - - @Schema(description = "날씨 상태", example = "맑음") - private String condition; - - @Schema(description = "습도 (%)", example = "65.0") - private Double humidity; -} diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index c3caad4..018f81b 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -24,23 +24,23 @@ spring: port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} -ai: - service: - url: ${AI_SERVICE_URL:http://localhost:8080/ai} - timeout: ${AI_SERVICE_TIMEOUT:30000} - - external: - claude-ai: - api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} - base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} - model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} - max-tokens: ${CLAUDE_AI_MAX_TOKENS:2000} - weather-api: - api-key: ${WEATHER_API_KEY:your-weather-api-key} - base-url: ${WEATHER_API_BASE_URL:https://api.openweathermap.org/data/2.5} 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:8090} + 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 springdoc: swagger-ui: @@ -56,4 +56,4 @@ logging: jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} - refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java index 50d69a1..537a189 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -3,6 +3,7 @@ package com.won.smarketing.content; 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.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; /** @@ -17,8 +18,9 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "com.won.smarketing.content.infrastructure.repository" }) @EntityScan(basePackages = { - "com.won.smarketing.content.domain.model" + "com.won.smarketing.content.infrastructure.entity" }) +@EnableJpaAuditing public class MarketingContentServiceApplication { public static void main(String[] args) { diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java new file mode 100644 index 0000000..3931d19 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java @@ -0,0 +1,9 @@ +package com.won.smarketing.content.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan(basePackages = "com.won.smarketing.content") +public class ContentConfig { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java index 549520c..9a19b77 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -1,14 +1,10 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java package com.won.smarketing.content.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.annotation.LastModifiedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; import java.util.ArrayList; @@ -17,615 +13,151 @@ import java.util.List; /** * 콘텐츠 도메인 모델 * - * 이 클래스는 마케팅 콘텐츠의 핵심 정보와 비즈니스 로직을 포함하는 - * DDD(Domain-Driven Design) 엔티티입니다. - * - * Clean Architecture의 Domain Layer에 위치하며, - * 비즈니스 규칙과 도메인 로직을 캡슐화합니다. + * Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티 + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer에서 별도의 JPA 엔티티로 매핑 */ -@Entity -@Table( - name = "contents", - indexes = { - @Index(name = "idx_store_id", columnList = "store_id"), - @Index(name = "idx_content_type", columnList = "content_type"), - @Index(name = "idx_platform", columnList = "platform"), - @Index(name = "idx_status", columnList = "status"), - @Index(name = "idx_promotion_dates", columnList = "promotion_start_date, promotion_end_date"), - @Index(name = "idx_created_at", columnList = "created_at") - } -) @Getter @NoArgsConstructor @AllArgsConstructor @Builder -@EntityListeners(AuditingEntityListener.class) public class Content { // ==================== 기본키 및 식별자 ==================== - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "id") private Long id; // ==================== 콘텐츠 분류 ==================== - - @Enumerated(EnumType.STRING) - @Column(name = "content_type", nullable = false, length = 20) private ContentType contentType; - - @Enumerated(EnumType.STRING) - @Column(name = "platform", nullable = false, length = 20) private Platform platform; // ==================== 콘텐츠 내용 ==================== - - @Column(name = "title", nullable = false, length = 200) private String title; - - @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; // ==================== 멀티미디어 및 메타데이터 ==================== - - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable( - name = "content_hashtags", - joinColumns = @JoinColumn(name = "content_id"), - indexes = @Index(name = "idx_content_hashtags", columnList = "content_id") - ) - @Column(name = "hashtag", length = 100) @Builder.Default private List hashtags = new ArrayList<>(); - @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable( - name = "content_images", - joinColumns = @JoinColumn(name = "content_id"), - indexes = @Index(name = "idx_content_images", columnList = "content_id") - ) - @Column(name = "image_url", length = 500) @Builder.Default private List images = new ArrayList<>(); // ==================== 상태 관리 ==================== + private ContentStatus status; - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 20) - @Builder.Default - private ContentStatus status = ContentStatus.DRAFT; - - // ==================== AI 생성 조건 (Embedded) ==================== - //@Embedded - @AttributeOverrides({ - @AttributeOverride(name = "toneAndManner", column = @Column(name = "tone_and_manner", length = 50)), - @AttributeOverride(name = "promotionType", column = @Column(name = "promotion_type", length = 50)), - @AttributeOverride(name = "emotionIntensity", column = @Column(name = "emotion_intensity", length = 50)), - @AttributeOverride(name = "targetAudience", column = @Column(name = "target_audience", length = 50)), - @AttributeOverride(name = "eventName", column = @Column(name = "event_name", length = 100)) - }) + // ==================== 생성 조건 ==================== private CreationConditions creationConditions; - // ==================== 비즈니스 관계 ==================== - - @Column(name = "store_id", nullable = false) + // ==================== 매장 정보 ==================== private Long storeId; - // ==================== 홍보 기간 ==================== - - @Column(name = "promotion_start_date") + // ==================== 프로모션 기간 ==================== private LocalDateTime promotionStartDate; - - @Column(name = "promotion_end_date") private LocalDateTime promotionEndDate; - // ==================== 감사(Audit) 정보 ==================== - - @CreatedDate - @Column(name = "created_at", nullable = false, updatable = false) + // ==================== 메타데이터 ==================== private LocalDateTime createdAt; - - @LastModifiedDate - @Column(name = "updated_at") private LocalDateTime updatedAt; public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { } - // ==================== 비즈니스 로직 메서드 ==================== + // ==================== 비즈니스 메서드 ==================== /** * 콘텐츠 제목 수정 - * - * 비즈니스 규칙: - * - 제목은 null이거나 빈 값일 수 없음 - * - 200자를 초과할 수 없음 - * - 발행된 콘텐츠는 제목 변경 시 상태가 DRAFT로 변경됨 - * - * @param title 새로운 제목 - * @throws IllegalArgumentException 제목이 유효하지 않은 경우 + * @param newTitle 새로운 제목 */ - public void updateTitle(String title) { - validateTitle(title); - - boolean wasPublished = isPublished(); - this.title = title.trim(); - - // 발행된 콘텐츠의 제목이 변경되면 재검토 필요 - if (wasPublished) { - this.status = ContentStatus.DRAFT; + public void updateTitle(String newTitle) { + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); } + this.title = newTitle.trim(); + this.updatedAt = LocalDateTime.now(); } /** * 콘텐츠 내용 수정 - * - * 비즈니스 규칙: - * - 내용은 null이거나 빈 값일 수 없음 - * - 발행된 콘텐츠는 내용 변경 시 상태가 DRAFT로 변경됨 - * - * @param content 새로운 콘텐츠 내용 - * @throws IllegalArgumentException 내용이 유효하지 않은 경우 + * @param newContent 새로운 내용 */ - public void updateContent(String content) { - validateContent(content); + public void updateContent(String newContent) { + this.content = newContent; + this.updatedAt = LocalDateTime.now(); + } - boolean wasPublished = isPublished(); - this.content = content.trim(); - - // 발행된 콘텐츠의 내용이 변경되면 재검토 필요 - if (wasPublished) { - this.status = ContentStatus.DRAFT; + /** + * 프로모션 기간 설정 + * @param startDate 시작일 + * @param endDate 종료일 + */ + public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다."); } + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + this.updatedAt = LocalDateTime.now(); } /** * 콘텐츠 상태 변경 - * - * 비즈니스 규칙: - * - PUBLISHED 상태로 변경시 유효성 검증 수행 - * - ARCHIVED 상태에서는 PUBLISHED로만 변경 가능 - * - * @param status 새로운 상태 - * @throws IllegalStateException 잘못된 상태 전환인 경우 + * @param newStatus 새로운 상태 */ -// public void changeStatus(ContentStatus status) { -// validateStatusTransition(this.status, status); -// -// if (status == ContentStatus.PUBLISHED) { -// validateForPublication(); -// } -// -// this.status = status; -// } - - /** - * 홍보 기간 설정 - * - * 비즈니스 규칙: - * - 시작일은 종료일보다 이전이어야 함 - * - 과거 날짜로 설정 불가 (현재 시간 기준) - * - * @param startDate 홍보 시작일 - * @param endDate 홍보 종료일 - * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 - */ - public void setPromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { - validatePromotionPeriod(startDate, endDate); - - this.promotionStartDate = startDate; - this.promotionEndDate = endDate; - } - - /** - * 홍보 기간 설정 - * - * 비즈니스 규칙: - * - 시작일은 종료일보다 이전이어야 함 - * - 과거 날짜로 설정 불가 (현재 시간 기준) - * - * @param startDate 홍보 시작일 - * @param endDate 홍보 종료일 - * @throws IllegalArgumentException 날짜가 유효하지 않은 경우 - */ - public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { - validatePromotionPeriod(startDate, endDate); - - this.promotionStartDate = startDate; - this.promotionEndDate = endDate; + public void updateStatus(ContentStatus newStatus) { + if (newStatus == null) { + throw new IllegalArgumentException("상태는 필수입니다."); + } + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); } /** * 해시태그 추가 - * - * @param hashtag 추가할 해시태그 (# 없이) + * @param hashtag 추가할 해시태그 */ public void addHashtag(String hashtag) { if (hashtag != null && !hashtag.trim().isEmpty()) { - String cleanHashtag = hashtag.trim().replace("#", ""); - if (!this.hashtags.contains(cleanHashtag)) { - this.hashtags.add(cleanHashtag); + if (this.hashtags == null) { + this.hashtags = new ArrayList<>(); } - } - } - - /** - * 해시태그 제거 - * - * @param hashtag 제거할 해시태그 - */ - public void removeHashtag(String hashtag) { - if (hashtag != null) { - String cleanHashtag = hashtag.trim().replace("#", ""); - this.hashtags.remove(cleanHashtag); + this.hashtags.add(hashtag.trim()); + this.updatedAt = LocalDateTime.now(); } } /** * 이미지 추가 - * - * @param imageUrl 이미지 URL + * @param imageUrl 추가할 이미지 URL */ public void addImage(String imageUrl) { if (imageUrl != null && !imageUrl.trim().isEmpty()) { - if (!this.images.contains(imageUrl.trim())) { - this.images.add(imageUrl.trim()); + if (this.images == null) { + this.images = new ArrayList<>(); } + this.images.add(imageUrl.trim()); + this.updatedAt = LocalDateTime.now(); } } /** - * 이미지 제거 - * - * @param imageUrl 제거할 이미지 URL + * 프로모션 진행 중 여부 확인 + * @return 현재 시간이 프로모션 기간 내에 있으면 true */ - public void removeImage(String imageUrl) { - if (imageUrl != null) { - this.images.remove(imageUrl.trim()); - } - } - - // ==================== 도메인 조회 메서드 ==================== - - /** - * 발행 상태 확인 - * - * @return 발행된 상태이면 true - */ - public boolean isPublished() { - return this.status == ContentStatus.PUBLISHED; - } - - /** - * 수정 가능 상태 확인 - * - * @return 임시저장 또는 예약발행 상태이면 true - */ - public boolean isEditable() { - return this.status == ContentStatus.DRAFT || this.status == ContentStatus.PUBLISHED; - } - - /** - * 현재 홍보 진행 중인지 확인 - * - * @return 홍보 기간 내이고 발행 상태이면 true - */ - public boolean isOngoingPromotion() { - if (!isPublished() || promotionStartDate == null || promotionEndDate == null) { - return false; - } - - LocalDateTime now = LocalDateTime.now(); - return now.isAfter(promotionStartDate) && now.isBefore(promotionEndDate); - } - - /** - * 홍보 예정 상태 확인 - * - * @return 홍보 시작 전이면 true - */ - public boolean isUpcomingPromotion() { - if (promotionStartDate == null) { - return false; - } - - return LocalDateTime.now().isBefore(promotionStartDate); - } - - /** - * 홍보 완료 상태 확인 - * - * @return 홍보 종료 후이면 true - */ - public boolean isCompletedPromotion() { - if (promotionEndDate == null) { - return false; - } - - return LocalDateTime.now().isAfter(promotionEndDate); - } - - /** - * SNS 콘텐츠 여부 확인 - * - * @return SNS 게시물이면 true - */ -// public boolean isSnsContent() { -// return this.contentType == ContentType.SNS_POST; -// } - - /** - * 포스터 콘텐츠 여부 확인 - * - * @return 포스터이면 true - */ - public boolean isPosterContent() { - return this.contentType == ContentType.POSTER; - } - - /** - * 이미지가 있는 콘텐츠인지 확인 - * - * @return 이미지가 1개 이상 있으면 true - */ - public boolean hasImages() { - return this.images != null && !this.images.isEmpty(); - } - - /** - * 해시태그가 있는 콘텐츠인지 확인 - * - * @return 해시태그가 1개 이상 있으면 true - */ - public boolean hasHashtags() { - return this.hashtags != null && !this.hashtags.isEmpty(); - } - - // ==================== 유효성 검증 메서드 ==================== - - /** - * 제목 유효성 검증 - */ - private void validateTitle(String title) { - if (title == null || title.trim().isEmpty()) { - throw new IllegalArgumentException("제목은 필수 입력 사항입니다."); - } - if (title.trim().length() > 200) { - throw new IllegalArgumentException("제목은 200자를 초과할 수 없습니다."); - } - } - - /** - * 내용 유효성 검증 - */ - private void validateContent(String content) { - if (content == null || content.trim().isEmpty()) { - throw new IllegalArgumentException("콘텐츠 내용은 필수 입력 사항입니다."); - } - } - - /** - * 홍보 기간 유효성 검증 - */ - private void validatePromotionPeriod(LocalDateTime startDate, LocalDateTime endDate) { - if (startDate == null || endDate == null) { - throw new IllegalArgumentException("홍보 시작일과 종료일은 필수 입력 사항입니다."); - } - if (startDate.isAfter(endDate)) { - throw new IllegalArgumentException("홍보 시작일은 종료일보다 이전이어야 합니다."); - } - if (endDate.isBefore(LocalDateTime.now())) { - throw new IllegalArgumentException("홍보 종료일은 현재 시간 이후여야 합니다."); - } - } - - /** - * 상태 전환 유효성 검증 - */ -// private void validateStatusTransition(ContentStatus from, ContentStatus to) { -// if (from == ContentStatus.ARCHIVED && to != ContentStatus.PUBLISHED) { -// throw new IllegalStateException("보관된 콘텐츠는 발행 상태로만 변경할 수 있습니다."); -// } -// } - - /** - * 발행을 위한 유효성 검증 - */ - private void validateForPublication() { - validateTitle(this.title); - validateContent(this.content); - - if (this.promotionStartDate == null || this.promotionEndDate == null) { - throw new IllegalStateException("발행하려면 홍보 기간을 설정해야 합니다."); - } - - if (this.contentType == ContentType.POSTER && !hasImages()) { - throw new IllegalStateException("포스터 콘텐츠는 이미지가 필수입니다."); - } - } - - // ==================== 비즈니스 계산 메서드 ==================== - - /** - * 홍보 진행률 계산 (0-100%) - * - * @return 진행률 - */ - public double calculateProgress() { + public boolean isPromotionActive() { if (promotionStartDate == null || promotionEndDate == null) { - return 0.0; + return false; } - LocalDateTime now = LocalDateTime.now(); - - if (now.isBefore(promotionStartDate)) { - return 0.0; - } else if (now.isAfter(promotionEndDate)) { - return 100.0; - } - - long totalDuration = java.time.Duration.between(promotionStartDate, promotionEndDate).toHours(); - long elapsedDuration = java.time.Duration.between(promotionStartDate, now).toHours(); - - if (totalDuration == 0) { - return 100.0; - } - - return (double) elapsedDuration / totalDuration * 100.0; + return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate); } /** - * 남은 홍보 일수 계산 - * - * @return 남은 일수 (음수면 0) + * 콘텐츠 게시 가능 여부 확인 + * @return 필수 정보가 모두 입력되어 있으면 true */ - public long calculateRemainingDays() { - if (promotionEndDate == null) { - return 0L; - } - - LocalDateTime now = LocalDateTime.now(); - if (now.isAfter(promotionEndDate)) { - return 0L; - } - - return java.time.Duration.between(now, promotionEndDate).toDays(); + public boolean canBePublished() { + return title != null && !title.trim().isEmpty() + && contentType != null + && platform != null + && storeId != null; } - - // ==================== 팩토리 메서드 ==================== - - /** - * SNS 콘텐츠 생성 팩토리 메서드 - */ - public static Content createSnsContent(String title, String content, Platform platform, - Long storeId, CreationConditions conditions) { - Content snsContent = Content.builder() -// .contentType(ContentType.SNS_POST) - .platform(platform) - .title(title) - .content(content) - .storeId(storeId) - .creationConditions(conditions) - .status(ContentStatus.DRAFT) - .hashtags(new ArrayList<>()) - .images(new ArrayList<>()) - .build(); - - // 유효성 검증 - snsContent.validateTitle(title); - snsContent.validateContent(content); - - return snsContent; - } - - /** - * 포스터 콘텐츠 생성 팩토리 메서드 - */ - public static Content createPosterContent(String title, String content, List images, - Long storeId, CreationConditions conditions) { - if (images == null || images.isEmpty()) { - throw new IllegalArgumentException("포스터 콘텐츠는 이미지가 필수입니다."); - } - - Content posterContent = Content.builder() - .contentType(ContentType.POSTER) - .platform(Platform.INSTAGRAM) // 기본값 - .title(title) - .content(content) - .storeId(storeId) - .creationConditions(conditions) - .status(ContentStatus.DRAFT) - .hashtags(new ArrayList<>()) - .images(new ArrayList<>(images)) - .build(); - - // 유효성 검증 - posterContent.validateTitle(title); - posterContent.validateContent(content); - - return posterContent; - } - - // ==================== Object 메서드 오버라이드 ==================== - - /** - * 비즈니스 키 기반 동등성 비교 - * JPA 엔티티에서는 ID가 아닌 비즈니스 키 사용 권장 - */ - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - - Content content = (Content) obj; - return id != null && id.equals(content.id); - } - - @Override - public int hashCode() { - return getClass().hashCode(); - } - - /** - * 디버깅용 toString (민감한 정보 제외) - */ - @Override - public String toString() { - return "Content{" + - "id=" + id + - ", contentType=" + contentType + - ", platform=" + platform + - ", title='" + title + '\'' + - ", status=" + status + - ", storeId=" + storeId + - ", promotionStartDate=" + promotionStartDate + - ", promotionEndDate=" + promotionEndDate + - ", createdAt=" + createdAt + - '}'; - } -} - -/* -==================== 데이터베이스 스키마 (참고용) ==================== - -CREATE TABLE contents ( - content_id BIGINT NOT NULL AUTO_INCREMENT, - content_type VARCHAR(20) NOT NULL, - platform VARCHAR(20) NOT NULL, - title VARCHAR(200) NOT NULL, - content TEXT NOT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', - tone_and_manner VARCHAR(50), - promotion_type VARCHAR(50), - emotion_intensity VARCHAR(50), - target_audience VARCHAR(50), - event_name VARCHAR(100), - store_id BIGINT NOT NULL, - promotion_start_date DATETIME, - promotion_end_date DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME, - PRIMARY KEY (content_id), - INDEX idx_store_id (store_id), - INDEX idx_content_type (content_type), - INDEX idx_platform (platform), - INDEX idx_status (status), - INDEX idx_promotion_dates (promotion_start_date, promotion_end_date), - INDEX idx_created_at (created_at) -); - -CREATE TABLE content_hashtags ( - content_id BIGINT NOT NULL, - hashtag VARCHAR(100) NOT NULL, - INDEX idx_content_hashtags (content_id), - FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE -); - -CREATE TABLE content_images ( - content_id BIGINT NOT NULL, - image_url VARCHAR(500) NOT NULL, - INDEX idx_content_images (content_id), - FOREIGN KEY (content_id) REFERENCES contents(content_id) ON DELETE CASCADE -); -*/ \ No newline at end of file +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java index 25220a8..2f07e2c 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -1,53 +1,51 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java package com.won.smarketing.content.domain.model; -import jakarta.persistence.Embeddable; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.util.Objects; +import lombok.RequiredArgsConstructor; /** * 콘텐츠 ID 값 객체 - * Clean Architecture의 Domain Layer에 위치하는 식별자 + * Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리 */ -@Embeddable @Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RequiredArgsConstructor +@EqualsAndHashCode public class ContentId { - private Long value; + private final Long value; /** - * ContentId 생성 팩토리 메서드 + * Long 값으로부터 ContentId 생성 * @param value ID 값 * @return ContentId 인스턴스 */ public static ContentId of(Long value) { if (value == null || value <= 0) { - throw new IllegalArgumentException("ContentId 값은 양수여야 합니다."); + throw new IllegalArgumentException("ContentId는 양수여야 합니다."); } return new ContentId(value); } - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ContentId contentId = (ContentId) o; - return Objects.equals(value, contentId.value); + /** + * 새로운 ContentId 생성 (ID가 없는 경우) + * @return null 값을 가진 ContentId + */ + public static ContentId newId() { + return new ContentId(null); } - @Override - public int hashCode() { - return Objects.hash(value); + /** + * ID 값 존재 여부 확인 + * @return ID가 null이 아니면 true + */ + public boolean hasValue() { + return value != null; } @Override public String toString() { - return "ContentId{" + value + '}'; + return "ContentId{" + "value=" + value + '}'; } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java index cb1f914..d7a9543 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -1,7 +1,6 @@ // marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java package com.won.smarketing.content.domain.model; -import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -12,57 +11,48 @@ import java.time.LocalDate; /** * 콘텐츠 생성 조건 도메인 모델 * Clean Architecture의 Domain Layer에 위치하는 값 객체 + * + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer의 JPA 엔티티는 별도로 관리 */ -@Entity -@Table(name = "contents_conditions") @Getter @NoArgsConstructor @AllArgsConstructor @Builder public class CreationConditions { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - //@OneToOne(mappedBy = "creationConditions") - @Column(name = "content", length = 100) - private Content content; - - @Column(name = "category", length = 100) + private String id; private String category; - - @Column(name = "requirement", columnDefinition = "TEXT") private String requirement; - - @Column(name = "tone_and_manner", length = 100) private String toneAndManner; - - @Column(name = "emotion_intensity", length = 100) private String emotionIntensity; - - @Column(name = "event_name", length = 200) private String eventName; - - @Column(name = "start_date") private LocalDate startDate; - - @Column(name = "end_date") private LocalDate endDate; - - @Column(name = "photo_style", length = 100) private String photoStyle; - - @Column(name = "promotionType", length = 100) private String promotionType; public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { } -// /** -// * 콘텐츠와의 연관관계 설정 -// * @param content 연관된 콘텐츠 -// */ -// public void setContent(Content content) { -// this.content = content; -// } + + /** + * 이벤트 기간 유효성 검증 + * @return 시작일이 종료일보다 이전이거나 같으면 true + */ + public boolean isValidEventPeriod() { + if (startDate == null || endDate == null) { + return true; + } + return !startDate.isAfter(endDate); + } + + /** + * 이벤트 조건 유무 확인 + * @return 이벤트명이나 날짜가 설정되어 있으면 true + */ + public boolean hasEventInfo() { + return eventName != null && !eventName.trim().isEmpty() + || startDate != null + || endDate != null; + } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java index 940bbba..b549b05 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -11,6 +11,7 @@ import java.time.LocalDate; /** * 콘텐츠 생성 조건 JPA 엔티티 + * Infrastructure Layer에서 데이터베이스 매핑을 담당 */ @Entity @Table(name = "content_conditions") @@ -50,9 +51,34 @@ public class ContentConditionsJpaEntity { @Column(name = "photo_style", length = 100) private String photoStyle; - @Column(name = "target_audience", length = 200) - private String targetAudience; - @Column(name = "promotion_type", length = 100) private String promotionType; + + // 생성자 + public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + this.content = content; + this.category = category; + this.requirement = requirement; + this.toneAndManner = toneAndManner; + this.emotionIntensity = emotionIntensity; + this.eventName = eventName; + this.startDate = startDate; + this.endDate = endDate; + this.photoStyle = photoStyle; + this.promotionType = promotionType; + } + + public ContentConditionsJpaEntity() { + + } + + // 팩토리 메서드 + public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity, + eventName, startDate, endDate, photoStyle, promotionType); + } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java index 2bd786a..bcc8499 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -10,6 +10,7 @@ import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.util.Date; /** * 콘텐츠 JPA 엔티티 @@ -37,6 +38,12 @@ public class ContentJpaEntity { @Column(name = "title", length = 500) private String title; + @Column(name = "PromotionStartDate") + private LocalDateTime PromotionStartDate; + + @Column(name = "PromotionEndDate") + private LocalDateTime PromotionEndDate; + @Column(name = "content", columnDefinition = "TEXT") private String content; diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java index 5cf42a4..9d72f1f 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -1,8 +1,10 @@ // marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java package com.won.smarketing.content.infrastructure.external; +// 수정: domain 패키지의 인터페이스를 import +import com.won.smarketing.content.domain.service.AiContentGenerator; import com.won.smarketing.content.domain.model.Platform; -import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -21,105 +23,73 @@ public class ClaudeAiContentGenerator implements AiContentGenerator { /** * SNS 콘텐츠 생성 - * Claude AI API를 호출하여 SNS 게시물을 생성합니다. - * - * @param title 제목 - * @param category 카테고리 - * @param platform 플랫폼 - * @param conditions 생성 조건 - * @return 생성된 콘텐츠 텍스트 */ @Override - public String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions) { + public String generateSnsContent(SnsContentCreateRequest request) { try { - // Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용) - String prompt = buildContentPrompt(title, category, platform, conditions); - - // TODO: 실제 Claude AI API 호출 - // 현재는 더미 데이터 반환 - return generateDummySnsContent(title, platform); - + String prompt = buildContentPrompt(request); + return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform())); } catch (Exception e) { log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); - return generateFallbackContent(title, platform); + return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); } } /** - * 해시태그 생성 - * 콘텐츠 내용을 분석하여 관련 해시태그를 생성합니다. - * - * @param content 콘텐츠 내용 - * @param platform 플랫폼 - * @return 생성된 해시태그 목록 + * 플랫폼별 해시태그 생성 */ @Override public List generateHashtags(String content, Platform platform) { try { - // TODO: 실제 Claude AI API 호출하여 해시태그 생성 - // 현재는 더미 데이터 반환 return generateDummyHashtags(platform); - } catch (Exception e) { log.error("해시태그 생성 실패: {}", e.getMessage(), e); - return Arrays.asList("#맛집", "#신메뉴", "#추천"); + return generateFallbackHashtags(); } } - /** - * AI 프롬프트 생성 - */ - private String buildContentPrompt(String title, String category, Platform platform, CreationConditions conditions) { + private String buildContentPrompt(SnsContentCreateRequest request) { StringBuilder prompt = new StringBuilder(); - prompt.append("다음 조건에 맞는 ").append(platform.getDisplayName()).append(" 게시물을 작성해주세요:\n"); - prompt.append("제목: ").append(title).append("\n"); - prompt.append("카테고리: ").append(category).append("\n"); + prompt.append("제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + prompt.append("플랫폼: ").append(request.getPlatform()).append("\n"); - if (conditions.getRequirement() != null) { - prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n"); + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); } - if (conditions.getToneAndManner() != null) { - prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n"); - } - if (conditions.getEmotionIntensity() != null) { - prompt.append("감정 강도: ").append(conditions.getEmotionIntensity()).append("\n"); + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); } return prompt.toString(); } - /** - * 더미 SNS 콘텐츠 생성 (개발용) - */ private String generateDummySnsContent(String title, Platform platform) { - switch (platform) { - case INSTAGRAM: - return String.format("🎉 %s\n\n맛있는 순간을 놓치지 마세요! 새로운 맛의 경험이 여러분을 기다리고 있어요. 따뜻한 분위기에서 즐기는 특별한 시간을 만들어보세요.\n\n📍 지금 바로 방문해보세요!", title); - case NAVER_BLOG: - return String.format("안녕하세요! 오늘은 %s에 대해 소개해드리려고 해요.\n\n정성스럽게 준비한 새로운 메뉴로 고객 여러분께 더 나은 경험을 선사하고 싶습니다. 많은 관심과 사랑 부탁드려요!", title); - default: - return String.format("%s - 새로운 경험을 만나보세요!", title); + String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" + + "저희 매장에서 특별한 경험을 만나보세요.\n" + + "고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n"; + + if (platform == Platform.INSTAGRAM) { + return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸"; + } else { + return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨"; } } - /** - * 더미 해시태그 생성 (개발용) - */ - private List generateDummyHashtags(Platform platform) { - switch (platform) { - case INSTAGRAM: - return Arrays.asList("#맛집", "#신메뉴", "#인스타그램", "#데일리", "#추천", "#음식스타그램"); - case NAVER_BLOG: - return Arrays.asList("#맛집", "#리뷰", "#추천", "#신메뉴", "#블로그"); - default: - return Arrays.asList("#맛집", "#신메뉴", "#추천"); - } - } - - /** - * 폴백 콘텐츠 생성 (AI 서비스 실패 시) - */ private String generateFallbackContent(String title, Platform platform) { - return String.format("🎉 %s\n\n새로운 소식을 전해드립니다. 많은 관심 부탁드려요!", title); + return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!"; + } + + private List generateDummyHashtags(Platform platform) { + if (platform == Platform.INSTAGRAM) { + return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램"); + } else { + return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원"); + } + } + + private List generateFallbackHashtags() { + return Arrays.asList("#소상공인", "#마케팅", "#홍보"); } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java index a667545..7495966 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -1,7 +1,8 @@ // marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java package com.won.smarketing.content.infrastructure.external; -import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -19,23 +20,20 @@ import java.util.Map; public class ClaudeAiPosterGenerator implements AiPosterGenerator { /** - * 포스터 이미지 생성 - * Claude AI API를 호출하여 홍보 포스터를 생성합니다. + * 포스터 생성 * - * @param title 제목 - * @param category 카테고리 - * @param conditions 생성 조건 + * @param request 포스터 생성 요청 * @return 생성된 포스터 이미지 URL */ @Override - public String generatePoster(String title, String category, CreationConditions conditions) { + public String generatePoster(PosterContentCreateRequest request) { try { - // Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용) - String prompt = buildPosterPrompt(title, category, conditions); + // Claude AI API 호출 로직 + String prompt = buildPosterPrompt(request); // TODO: 실제 Claude AI API 호출 // 현재는 더미 데이터 반환 - return generateDummyPosterUrl(title); + return generateDummyPosterUrl(request.getTitle()); } catch (Exception e) { log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); @@ -44,75 +42,45 @@ public class ClaudeAiPosterGenerator implements AiPosterGenerator { } /** - * 포스터 다양한 사이즈 생성 - * 원본 포스터를 기반으로 다양한 사이즈의 포스터를 생성합니다. + * 다양한 사이즈의 포스터 생성 * - * @param originalImage 원본 이미지 URL - * @return 사이즈별 이미지 URL 맵 + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 */ @Override - public Map generatePosterSizes(String originalImage) { - try { - // TODO: 실제 이미지 리사이징 API 호출 - // 현재는 더미 데이터 반환 - return generateDummyPosterSizes(originalImage); + public Map generatePosterSizes(String baseImage) { + Map sizes = new HashMap<>(); - } catch (Exception e) { - log.error("포스터 사이즈 생성 실패: {}", e.getMessage(), e); - return new HashMap<>(); - } + // 다양한 사이즈 생성 (더미 구현) + sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); + sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); + sizes.put("facebook_post", baseImage + "_1200x630.jpg"); + sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); + + return sizes; } - /** - * AI 포스터 프롬프트 생성 - */ - private String buildPosterPrompt(String title, String category, CreationConditions conditions) { + private String buildPosterPrompt(PosterContentCreateRequest request) { StringBuilder prompt = new StringBuilder(); - prompt.append("다음 조건에 맞는 홍보 포스터를 생성해주세요:\n"); - prompt.append("제목: ").append(title).append("\n"); - prompt.append("카테고리: ").append(category).append("\n"); + prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); - if (conditions.getPhotoStyle() != null) { - prompt.append("사진 스타일: ").append(conditions.getPhotoStyle()).append("\n"); + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); } - if (conditions.getRequirement() != null) { - prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n"); - } - if (conditions.getToneAndManner() != null) { - prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n"); + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); } return prompt.toString(); } - /** - * 더미 포스터 URL 생성 (개발용) - */ private String generateDummyPosterUrl(String title) { - return String.format("https://example.com/posters/%s-poster.jpg", - title.replaceAll("\\s+", "-").toLowerCase()); + return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; } - /** - * 더미 포스터 사이즈별 URL 생성 (개발용) - */ - private Map generateDummyPosterSizes(String originalImage) { - Map sizes = new HashMap<>(); - String baseUrl = originalImage.substring(0, originalImage.lastIndexOf(".")); - String extension = originalImage.substring(originalImage.lastIndexOf(".")); - - sizes.put("small", baseUrl + "-small" + extension); - sizes.put("medium", baseUrl + "-medium" + extension); - sizes.put("large", baseUrl + "-large" + extension); - sizes.put("xlarge", baseUrl + "-xlarge" + extension); - - return sizes; - } - - /** - * 폴백 포스터 URL 생성 (AI 서비스 실패 시) - */ private String generateFallbackPosterUrl() { - return "https://example.com/posters/default-poster.jpg"; + return "https://dummy-ai-service.com/posters/fallback.jpg"; } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java index a03954f..44fdb68 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java package com.won.smarketing.content.infrastructure.mapper; import com.won.smarketing.content.domain.model.*; @@ -14,6 +15,7 @@ import java.util.List; /** * 콘텐츠 도메인-엔티티 매퍼 + * Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당 * * @author smarketing-team * @version 1.0 @@ -26,7 +28,7 @@ public class ContentMapper { private final ObjectMapper objectMapper; /** - * 도메인 모델을 JPA 엔티티로 변환합니다. + * 도메인 모델을 JPA 엔티티로 변환 * * @param content 도메인 콘텐츠 * @return JPA 엔티티 @@ -37,32 +39,30 @@ public class ContentMapper { } ContentJpaEntity entity = new ContentJpaEntity(); + + // 기본 필드 매핑 if (content.getId() != null) { entity.setId(content.getId()); } entity.setStoreId(content.getStoreId()); - entity.setContentType(content.getContentType().name()); + entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null); entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null); entity.setTitle(content.getTitle()); entity.setContent(content.getContent()); - entity.setHashtags(convertListToJson(content.getHashtags())); - entity.setImages(convertListToJson(content.getImages())); - entity.setStatus(content.getStatus().name()); + entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT"); + entity.setPromotionStartDate(content.getPromotionStartDate()); + entity.setPromotionEndDate(content.getPromotionEndDate()); entity.setCreatedAt(content.getCreatedAt()); entity.setUpdatedAt(content.getUpdatedAt()); - // 조건 정보 매핑 + // 컬렉션 필드를 JSON으로 변환 + entity.setHashtags(convertListToJson(content.getHashtags())); + entity.setImages(convertListToJson(content.getImages())); + + // 생성 조건 정보 매핑 if (content.getCreationConditions() != null) { - ContentConditionsJpaEntity conditionsEntity = new ContentConditionsJpaEntity(); + ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions()); conditionsEntity.setContent(entity); - conditionsEntity.setCategory(content.getCreationConditions().getCategory()); - conditionsEntity.setRequirement(content.getCreationConditions().getRequirement()); - conditionsEntity.setToneAndManner(content.getCreationConditions().getToneAndManner()); - conditionsEntity.setEmotionIntensity(content.getCreationConditions().getEmotionIntensity()); - conditionsEntity.setEventName(content.getCreationConditions().getEventName()); - conditionsEntity.setStartDate(content.getCreationConditions().getStartDate()); - conditionsEntity.setEndDate(content.getCreationConditions().getEndDate()); - conditionsEntity.setPhotoStyle(content.getCreationConditions().getPhotoStyle()); entity.setConditions(conditionsEntity); } @@ -70,50 +70,74 @@ public class ContentMapper { } /** - * JPA 엔티티를 도메인 모델로 변환합니다. + * JPA 엔티티를 도메인 모델로 변환 * * @param entity JPA 엔티티 - * @return 도메인 콘텐츠 + * @return 도메인 모델 */ public Content toDomain(ContentJpaEntity entity) { if (entity == null) { return null; } - CreationConditions conditions = null; - if (entity.getConditions() != null) { - conditions = new CreationConditions( - entity.getConditions().getCategory(), - entity.getConditions().getRequirement(), - entity.getConditions().getToneAndManner(), - entity.getConditions().getEmotionIntensity(), - entity.getConditions().getEventName(), - entity.getConditions().getStartDate(), - entity.getConditions().getEndDate(), - entity.getConditions().getPhotoStyle(), - // entity.getConditions().getTargetAudience(), - entity.getConditions().getPromotionType() - ); - } - - return new Content( - ContentId.of(entity.getId()), - ContentType.valueOf(entity.getContentType()), - entity.getPlatform() != null ? Platform.valueOf(entity.getPlatform()) : null, - entity.getTitle(), - entity.getContent(), - convertJsonToList(entity.getHashtags()), - convertJsonToList(entity.getImages()), - ContentStatus.valueOf(entity.getStatus()), - conditions, - entity.getStoreId(), - entity.getCreatedAt(), - entity.getUpdatedAt() - ); + return Content.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .contentType(parseContentType(entity.getContentType())) + .platform(parsePlatform(entity.getPlatform())) + .title(entity.getTitle()) + .content(entity.getContent()) + .hashtags(convertJsonToList(entity.getHashtags())) + .images(convertJsonToList(entity.getImages())) + .status(parseContentStatus(entity.getStatus())) + .promotionStartDate(entity.getPromotionStartDate()) + .promotionEndDate(entity.getPromotionEndDate()) + .creationConditions(mapToConditionsDomain(entity.getConditions())) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); } /** - * List를 JSON 문자열로 변환합니다. + * CreationConditions 도메인을 JPA 엔티티로 변환 + */ + private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) { + ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity(); + entity.setCategory(conditions.getCategory()); + entity.setRequirement(conditions.getRequirement()); + entity.setToneAndManner(conditions.getToneAndManner()); + entity.setEmotionIntensity(conditions.getEmotionIntensity()); + entity.setEventName(conditions.getEventName()); + entity.setStartDate(conditions.getStartDate()); + entity.setEndDate(conditions.getEndDate()); + entity.setPhotoStyle(conditions.getPhotoStyle()); + entity.setPromotionType(conditions.getPromotionType()); + return entity; + } + + /** + * CreationConditions JPA 엔티티를 도메인으로 변환 + */ + private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) { + if (entity == null) { + return null; + } + + return CreationConditions.builder() + .category(entity.getCategory()) + .requirement(entity.getRequirement()) + .toneAndManner(entity.getToneAndManner()) + .emotionIntensity(entity.getEmotionIntensity()) + .eventName(entity.getEventName()) + .startDate(entity.getStartDate()) + .endDate(entity.getEndDate()) + .photoStyle(entity.getPhotoStyle()) + .promotionType(entity.getPromotionType()) + .build(); + } + + /** + * List를 JSON 문자열로 변환 */ private String convertListToJson(List list) { if (list == null || list.isEmpty()) { @@ -128,7 +152,7 @@ public class ContentMapper { } /** - * JSON 문자열을 List로 변환합니다. + * JSON 문자열을 List로 변환 */ private List convertJsonToList(String json) { if (json == null || json.trim().isEmpty()) { @@ -141,4 +165,49 @@ public class ContentMapper { return Collections.emptyList(); } } -} + + /** + * 문자열을 ContentType 열거형으로 변환 + */ + private ContentType parseContentType(String contentType) { + if (contentType == null) { + return null; + } + try { + return ContentType.valueOf(contentType); + } catch (IllegalArgumentException e) { + log.warn("Unknown content type: {}", contentType); + return null; + } + } + + /** + * 문자열을 Platform 열거형으로 변환 + */ + private Platform parsePlatform(String platform) { + if (platform == null) { + return null; + } + try { + return Platform.valueOf(platform); + } catch (IllegalArgumentException e) { + log.warn("Unknown platform: {}", platform); + return null; + } + } + + /** + * 문자열을 ContentStatus 열거형으로 변환 + */ + private ContentStatus parseContentStatus(String status) { + if (status == null) { + return ContentStatus.DRAFT; + } + try { + return ContentStatus.valueOf(status); + } catch (IllegalArgumentException e) { + log.warn("Unknown content status: {}", status); + return ContentStatus.DRAFT; + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java index da461e5..f3f38ed 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -6,63 +6,100 @@ import com.won.smarketing.content.domain.model.ContentId; import com.won.smarketing.content.domain.model.ContentType; import com.won.smarketing.content.domain.model.Platform; import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.won.smarketing.content.infrastructure.mapper.ContentMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; /** * JPA를 활용한 콘텐츠 리포지토리 구현체 * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용 */ @Repository @RequiredArgsConstructor +@Slf4j public class JpaContentRepository implements ContentRepository { private final JpaContentRepositoryInterface jpaRepository; + private final ContentMapper contentMapper; /** * 콘텐츠 저장 - * @param content 저장할 콘텐츠 - * @return 저장된 콘텐츠 + * @param content 저장할 도메인 콘텐츠 + * @return 저장된 도메인 콘텐츠 */ @Override public Content save(Content content) { - return jpaRepository.save(content); + log.debug("Saving content: {}", content.getTitle()); + + // 도메인 모델을 JPA 엔티티로 변환 + ContentJpaEntity entity = contentMapper.toEntity(content); + + // JPA로 저장 + ContentJpaEntity savedEntity = jpaRepository.save(entity); + + // JPA 엔티티를 도메인 모델로 변환하여 반환 + Content savedContent = contentMapper.toDomain(savedEntity); + + log.debug("Content saved with ID: {}", savedContent.getId()); + return savedContent; } /** * ID로 콘텐츠 조회 * @param id 콘텐츠 ID - * @return 조회된 콘텐츠 + * @return 조회된 도메인 콘텐츠 */ @Override public Optional findById(ContentId id) { - return jpaRepository.findById(id.getValue()); + log.debug("Finding content by ID: {}", id.getValue()); + + return jpaRepository.findById(id.getValue()) + .map(contentMapper::toDomain); } /** * 필터 조건으로 콘텐츠 목록 조회 * @param contentType 콘텐츠 타입 * @param platform 플랫폼 - * @param period 기간 - * @param sortBy 정렬 기준 - * @return 콘텐츠 목록 + * @param period 기간 (현재는 사용하지 않음) + * @param sortBy 정렬 기준 (현재는 사용하지 않음) + * @return 도메인 콘텐츠 목록 */ @Override public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { - return jpaRepository.findByFilters(contentType, platform, period, sortBy); + log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform); + + String contentTypeStr = contentType != null ? contentType.name() : null; + String platformStr = platform != null ? platform.name() : null; + + List entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } /** * 진행 중인 콘텐츠 목록 조회 - * @param period 기간 - * @return 진행 중인 콘텐츠 목록 + * @param period 기간 (현재는 사용하지 않음) + * @return 진행 중인 도메인 콘텐츠 목록 */ @Override public List findOngoingContents(String period) { - return jpaRepository.findOngoingContents(period); + log.debug("Finding ongoing contents"); + + List entities = jpaRepository.findOngoingContents(); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } /** @@ -71,6 +108,40 @@ public class JpaContentRepository implements ContentRepository { */ @Override public void deleteById(ContentId id) { + log.debug("Deleting content by ID: {}", id.getValue()); + jpaRepository.deleteById(id.getValue()); + + log.debug("Content deleted successfully"); + } + + /** + * 매장 ID로 콘텐츠 목록 조회 (추가 메서드) + * @param storeId 매장 ID + * @return 도메인 콘텐츠 목록 + */ + public List findByStoreId(Long storeId) { + log.debug("Finding contents by store ID: {}", storeId); + + List entities = jpaRepository.findByStoreId(storeId); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 타입으로 조회 (추가 메서드) + * @param contentType 콘텐츠 타입 + * @return 도메인 콘텐츠 목록 + */ + public List findByContentType(ContentType contentType) { + log.debug("Finding contents by type: {}", contentType); + + List entities = jpaRepository.findByContentType(contentType.name()); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); } } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java index 380bba6..37c4e74 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -1,9 +1,7 @@ // marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java package com.won.smarketing.content.infrastructure.repository; -import com.won.smarketing.content.domain.model.Content; -import com.won.smarketing.content.domain.model.ContentType; -import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -13,37 +11,77 @@ import java.util.List; /** * Spring Data JPA 콘텐츠 리포지토리 인터페이스 * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근 */ -public interface JpaContentRepositoryInterface extends JpaRepository { +public interface JpaContentRepositoryInterface extends JpaRepository { + + /** + * 매장 ID로 콘텐츠 목록 조회 + * @param storeId 매장 ID + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입으로 조회 + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼으로 조회 + * @param platform 플랫폼 + * @return 콘텐츠 엔티티 목록 + */ + List findByPlatform(String platform); + + /** + * 상태로 조회 + * @param status 상태 + * @return 콘텐츠 엔티티 목록 + */ + List findByStatus(String status); /** * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 (null 가능) + * @param platform 플랫폼 (null 가능) + * @param status 상태 (null 가능) + * @return 콘텐츠 엔티티 목록 */ - @Query("SELECT c FROM Content c WHERE " + + @Query("SELECT c FROM ContentJpaEntity c WHERE " + "(:contentType IS NULL OR c.contentType = :contentType) AND " + "(:platform IS NULL OR c.platform = :platform) AND " + - "(:period IS NULL OR " + - " (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " + - " (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " + - " (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " + - "ORDER BY " + - "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + - "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC, " + - "CASE WHEN :sortBy = 'title' THEN c.title END ASC") - List findByFilters(@Param("contentType") ContentType contentType, - @Param("platform") Platform platform, - @Param("period") String period, - @Param("sortBy") String sortBy); + "(:status IS NULL OR c.status = :status) " + + "ORDER BY c.createdAt DESC") + List findByFilters(@Param("contentType") String contentType, + @Param("platform") String platform, + @Param("status") String status); /** - * 진행 중인 콘텐츠 목록 조회 + * 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠) + * @return 진행 중인 콘텐츠 엔티티 목록 */ - @Query("SELECT c FROM Content c WHERE " + - "c.status IN ('PUBLISHED', 'SCHEDULED') AND " + - "(:period IS NULL OR " + - " (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " + - " (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " + - " (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " + + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "c.status IN ('PUBLISHED', 'SCHEDULED') " + "ORDER BY c.createdAt DESC") - List findOngoingContents(@Param("period") String period); + List findOngoingContents(); + + /** + * 매장 ID와 콘텐츠 타입으로 조회 + * @param storeId 매장 ID + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreIdAndContentType(Long storeId, String contentType); + + /** + * 최근 생성된 콘텐츠 조회 (limit 적용) + * @param storeId 매장 ID + * @return 최근 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " + + "ORDER BY c.createdAt DESC") + List findRecentContentsByStoreId(@Param("storeId") Long storeId); } \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java deleted file mode 100644 index feba6b4..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/SpringDataContentRepository.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.won.smarketing.content.infrastructure.repository; - -import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; -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.List; - -/** - * Spring Data JPA 콘텐츠 Repository - * - * @author smarketing-team - * @version 1.0 - */ -@Repository -public interface SpringDataContentRepository extends JpaRepository { - - /** - * 필터 조건으로 콘텐츠를 조회합니다. - * - * @param contentType 콘텐츠 타입 - * @param platform 플랫폼 - * @param period 기간 - * @param sortBy 정렬 기준 - * @return 콘텐츠 목록 - */ - @Query("SELECT c FROM ContentJpaEntity c WHERE " + - "(:contentType IS NULL OR c.contentType = :contentType) AND " + - "(:platform IS NULL OR c.platform = :platform) AND " + - "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY) " + - "ORDER BY " + - "CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " + - "CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC") - List findByFilters(@Param("contentType") String contentType, - @Param("platform") String platform, - @Param("period") String period, - @Param("sortBy") String sortBy); - - /** - * 진행 중인 콘텐츠를 조회합니다. - * - * @param period 기간 - * @return 진행 중인 콘텐츠 목록 - */ - @Query("SELECT c FROM ContentJpaEntity c " + - "WHERE c.status = 'PUBLISHED' AND " + - "(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY)") - List findOngoingContents(@Param("period") String period); -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 0e9e68c..10dc73d 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -17,21 +17,17 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true -ai: - service: - url: ${AI_SERVICE_URL:http://localhost:8080/ai} - timeout: ${AI_SERVICE_TIMEOUT:30000} + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} -external: - claude-ai: - api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} - base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} - model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} - max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000} jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + logging: level: - com.won.smarketing.content: ${LOG_LEVEL:DEBUG} + com.won.smarketing: ${LOG_LEVEL:DEBUG}