mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2025-12-06 07:06:24 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
1575771b57
@ -4,12 +4,14 @@ import org.springframework.boot.SpringApplication;
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cache.annotation.EnableCaching;
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
/**
|
@SpringBootApplication(scanBasePackages = {
|
||||||
* AI 추천 서비스 메인 애플리케이션
|
"com.won.smarketing.recommend",
|
||||||
*/
|
"com.won.smarketing.common"
|
||||||
@SpringBootApplication
|
})
|
||||||
@EnableJpaAuditing
|
@EnableJpaAuditing
|
||||||
|
@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence")
|
||||||
@EnableCaching
|
@EnableCaching
|
||||||
public class AIRecommendServiceApplication {
|
public class AIRecommendServiceApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -5,11 +5,8 @@ import com.won.smarketing.common.exception.ErrorCode;
|
|||||||
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
|
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
|
||||||
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
||||||
import com.won.smarketing.recommend.domain.model.StoreData;
|
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.repository.MarketingTipRepository;
|
||||||
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
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.domain.service.AiTipGenerator;
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
|
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
|
||||||
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
||||||
@ -32,7 +29,6 @@ public class MarketingTipService implements MarketingTipUseCase {
|
|||||||
|
|
||||||
private final MarketingTipRepository marketingTipRepository;
|
private final MarketingTipRepository marketingTipRepository;
|
||||||
private final StoreDataProvider storeDataProvider;
|
private final StoreDataProvider storeDataProvider;
|
||||||
private final WeatherDataProvider weatherDataProvider;
|
|
||||||
private final AiTipGenerator aiTipGenerator;
|
private final AiTipGenerator aiTipGenerator;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -44,19 +40,14 @@ public class MarketingTipService implements MarketingTipUseCase {
|
|||||||
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
|
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
|
||||||
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
|
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
|
||||||
|
|
||||||
// 2. 날씨 정보 조회
|
// 2. Python AI 서비스로 팁 생성 (매장 정보 + 추가 요청사항 전달)
|
||||||
WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation());
|
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement());
|
||||||
log.debug("날씨 정보 조회 완료: 온도={}, 상태={}", weatherData.getTemperature(), weatherData.getCondition());
|
|
||||||
|
|
||||||
// 3. AI 팁 생성
|
|
||||||
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, weatherData, request.getAdditionalRequirement());
|
|
||||||
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
|
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
|
||||||
|
|
||||||
// 4. 도메인 객체 생성 및 저장
|
// 3. 도메인 객체 생성 및 저장
|
||||||
MarketingTip marketingTip = MarketingTip.builder()
|
MarketingTip marketingTip = MarketingTip.builder()
|
||||||
.storeId(request.getStoreId())
|
.storeId(request.getStoreId())
|
||||||
.tipContent(aiGeneratedTip)
|
.tipContent(aiGeneratedTip)
|
||||||
.weatherData(weatherData)
|
|
||||||
.storeData(storeData)
|
.storeData(storeData)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@ -67,7 +58,7 @@ public class MarketingTipService implements MarketingTipUseCase {
|
|||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
|
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
|
||||||
throw new BusinessException(ErrorCode.AI_TIP_GENERATION_FAILED);
|
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +79,7 @@ public class MarketingTipService implements MarketingTipUseCase {
|
|||||||
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
|
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
|
||||||
|
|
||||||
MarketingTip marketingTip = marketingTipRepository.findById(tipId)
|
MarketingTip marketingTip = marketingTipRepository.findById(tipId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.MARKETING_TIP_NOT_FOUND));
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR));
|
||||||
|
|
||||||
return convertToResponse(marketingTip);
|
return convertToResponse(marketingTip);
|
||||||
}
|
}
|
||||||
@ -98,32 +89,13 @@ public class MarketingTipService implements MarketingTipUseCase {
|
|||||||
.tipId(marketingTip.getId().getValue())
|
.tipId(marketingTip.getId().getValue())
|
||||||
.storeId(marketingTip.getStoreId())
|
.storeId(marketingTip.getStoreId())
|
||||||
.storeName(marketingTip.getStoreData().getStoreName())
|
.storeName(marketingTip.getStoreData().getStoreName())
|
||||||
.businessType(marketingTip.getStoreData().getBusinessType())
|
.tipContent(marketingTip.getTipContent())
|
||||||
.storeLocation(marketingTip.getStoreData().getLocation())
|
.storeInfo(MarketingTipResponse.StoreInfo.builder()
|
||||||
|
.storeName(marketingTip.getStoreData().getStoreName())
|
||||||
|
.businessType(marketingTip.getStoreData().getBusinessType())
|
||||||
|
.location(marketingTip.getStoreData().getLocation())
|
||||||
|
.build())
|
||||||
.createdAt(marketingTip.getCreatedAt())
|
.createdAt(marketingTip.getCreatedAt())
|
||||||
.build();
|
.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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,33 +6,22 @@ import org.springframework.data.domain.Page;
|
|||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 생성 유즈케이스 인터페이스
|
* 마케팅 팁 유즈케이스 인터페이스
|
||||||
* 비즈니스 요구사항을 정의하는 애플리케이션 계층의 인터페이스
|
|
||||||
*/
|
*/
|
||||||
public interface MarketingTipUseCase {
|
public interface MarketingTipUseCase {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 마케팅 팁 생성
|
* AI 마케팅 팁 생성
|
||||||
*
|
|
||||||
* @param request 마케팅 팁 생성 요청
|
|
||||||
* @return 생성된 마케팅 팁 정보
|
|
||||||
*/
|
*/
|
||||||
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
|
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 이력 조회
|
* 마케팅 팁 이력 조회
|
||||||
*
|
|
||||||
* @param storeId 매장 ID
|
|
||||||
* @param pageable 페이징 정보
|
|
||||||
* @return 마케팅 팁 이력 페이지
|
|
||||||
*/
|
*/
|
||||||
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
|
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 상세 조회
|
* 마케팅 팁 상세 조회
|
||||||
*
|
|
||||||
* @param tipId 팁 ID
|
|
||||||
* @return 마케팅 팁 상세 정보
|
|
||||||
*/
|
*/
|
||||||
MarketingTipResponse getMarketingTip(Long tipId);
|
MarketingTipResponse getMarketingTip(Long tipId);
|
||||||
}
|
}
|
||||||
@ -4,15 +4,13 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
import io.netty.channel.ChannelOption;
|
import io.netty.channel.ChannelOption;
|
||||||
import io.netty.handler.timeout.ConnectTimeoutHandler;
|
|
||||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||||
import reactor.netty.http.client.HttpClient;
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebClient 설정
|
* WebClient 설정 (간소화된 버전)
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebClientConfig {
|
public class WebClientConfig {
|
||||||
@ -21,9 +19,7 @@ public class WebClientConfig {
|
|||||||
public WebClient webClient() {
|
public WebClient webClient() {
|
||||||
HttpClient httpClient = HttpClient.create()
|
HttpClient httpClient = HttpClient.create()
|
||||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
|
||||||
.responseTimeout(Duration.ofMillis(5000))
|
.responseTimeout(Duration.ofMillis(5000));
|
||||||
.doOnConnected(conn -> conn
|
|
||||||
.addHandlerLast(new ConnectTimeoutHandler(5, TimeUnit.SECONDS)));
|
|
||||||
|
|
||||||
return WebClient.builder()
|
return WebClient.builder()
|
||||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
package com.won.smarketing.recommend.domain.model;
|
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.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@ -11,7 +8,7 @@ import lombok.NoArgsConstructor;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 도메인 모델
|
* 마케팅 팁 도메인 모델 (날씨 정보 제거)
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@Builder
|
@Builder
|
||||||
@ -22,15 +19,13 @@ public class MarketingTip {
|
|||||||
private TipId id;
|
private TipId id;
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
private String tipContent;
|
private String tipContent;
|
||||||
private WeatherData weatherData;
|
|
||||||
private StoreData storeData;
|
private StoreData storeData;
|
||||||
private LocalDateTime createdAt;
|
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()
|
return MarketingTip.builder()
|
||||||
.storeId(storeId)
|
.storeId(storeId)
|
||||||
.tipContent(tipContent)
|
.tipContent(tipContent)
|
||||||
.weatherData(weatherData)
|
|
||||||
.storeData(storeData)
|
.storeData(storeData)
|
||||||
.createdAt(LocalDateTime.now())
|
.createdAt(LocalDateTime.now())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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<BusinessInsight, Long> {
|
|
||||||
|
|
||||||
List<BusinessInsight> findByStoreIdOrderByCreatedAtDesc(Long storeId);
|
|
||||||
|
|
||||||
List<BusinessInsight> findByInsightTypeAndStoreId(String insightType, Long storeId);
|
|
||||||
}
|
|
||||||
@ -7,7 +7,7 @@ import org.springframework.data.domain.Pageable;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 레포지토리 인터페이스
|
* 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스)
|
||||||
*/
|
*/
|
||||||
public interface MarketingTipRepository {
|
public interface MarketingTipRepository {
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,18 @@
|
|||||||
package com.won.smarketing.recommend.domain.service;
|
package com.won.smarketing.recommend.domain.service;
|
||||||
|
|
||||||
import com.won.smarketing.recommend.domain.model.StoreData;
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
import com.won.smarketing.recommend.domain.model.WeatherData;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 팁 생성 도메인 서비스 인터페이스
|
* AI 팁 생성 도메인 서비스 인터페이스 (단순화)
|
||||||
* AI를 활용한 마케팅 팁 생성 기능 정의
|
|
||||||
*/
|
*/
|
||||||
public interface AiTipGenerator {
|
public interface AiTipGenerator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성
|
* Python AI 서비스를 통한 마케팅 팁 생성
|
||||||
*
|
*
|
||||||
* @param storeData 매장 데이터
|
* @param storeData 매장 정보
|
||||||
* @param weatherData 날씨 데이터
|
* @param additionalRequirement 추가 요청사항
|
||||||
* @return AI가 생성한 마케팅 팁
|
* @return AI가 생성한 마케팅 팁
|
||||||
*/
|
*/
|
||||||
String generateTip(StoreData storeData, WeatherData weatherData);
|
String generateTip(StoreData storeData, String additionalRequirement);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,15 +4,8 @@ import com.won.smarketing.recommend.domain.model.StoreData;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 매장 데이터 제공 도메인 서비스 인터페이스
|
* 매장 데이터 제공 도메인 서비스 인터페이스
|
||||||
* 외부 매장 서비스로부터 매장 정보 조회 기능 정의
|
|
||||||
*/
|
*/
|
||||||
public interface StoreDataProvider {
|
public interface StoreDataProvider {
|
||||||
|
|
||||||
/**
|
|
||||||
* 매장 정보 조회
|
|
||||||
*
|
|
||||||
* @param storeId 매장 ID
|
|
||||||
* @return 매장 데이터
|
|
||||||
*/
|
|
||||||
StoreData getStoreData(Long storeId);
|
StoreData getStoreData(Long storeId);
|
||||||
}
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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<String, Object> requestData = Map.of(
|
|
||||||
// "store_name", storeData.getStoreName(),
|
|
||||||
// "business_type", storeData.getBusinessType(),
|
|
||||||
// "location", storeData.getLocation(),
|
|
||||||
// "temperature", weatherData.getTemperature(),
|
|
||||||
// "weather_condition", weatherData.getCondition(),
|
|
||||||
// "humidity", weatherData.getHumidity(),
|
|
||||||
// "additional_requirement", additionalRequirement != null ? additionalRequirement : ""
|
|
||||||
// );
|
|
||||||
//
|
|
||||||
// PythonAiResponse response = webClient
|
|
||||||
// .post()
|
|
||||||
// .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip")
|
|
||||||
// .header("Authorization", "Bearer " + pythonAiServiceApiKey)
|
|
||||||
// .header("Content-Type", "application/json")
|
|
||||||
// .bodyValue(requestData)
|
|
||||||
// .retrieve()
|
|
||||||
// .bodyToMono(PythonAiResponse.class)
|
|
||||||
// .timeout(Duration.ofMillis(timeout))
|
|
||||||
// .block();
|
|
||||||
//
|
|
||||||
// if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) {
|
|
||||||
// return response.getTip();
|
|
||||||
// }
|
|
||||||
// } catch (Exception e) {
|
|
||||||
// log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return createFallbackTip(storeData, weatherData, additionalRequirement);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// private String createFallbackTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
|
|
||||||
// String businessType = storeData.getBusinessType();
|
|
||||||
// double temperature = weatherData.getTemperature();
|
|
||||||
// String condition = weatherData.getCondition();
|
|
||||||
// String storeName = storeData.getStoreName();
|
|
||||||
//
|
|
||||||
// // 추가 요청사항이 있는 경우 우선 반영
|
|
||||||
// if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
|
|
||||||
// return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!",
|
|
||||||
// storeName, additionalRequirement);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // 날씨와 업종 기반 규칙
|
|
||||||
// if (temperature > 25) {
|
|
||||||
// if (businessType.contains("카페")) {
|
|
||||||
// return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature);
|
|
||||||
// } else {
|
|
||||||
// return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!";
|
|
||||||
// }
|
|
||||||
// } else if (temperature < 10) {
|
|
||||||
// if (businessType.contains("카페")) {
|
|
||||||
// return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature);
|
|
||||||
// } else {
|
|
||||||
// return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!";
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if (condition.contains("비")) {
|
|
||||||
// return "비 오는 날에는 따뜻한 음료와 분위기로 고객들의 마음을 따뜻하게 해보세요!";
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // 기본 팁
|
|
||||||
// return String.format("%s에서 오늘(%.1f도, %s) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!",
|
|
||||||
// storeName, temperature, condition);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// private static class PythonAiResponse {
|
|
||||||
// private String tip;
|
|
||||||
// private String status;
|
|
||||||
// private String message;
|
|
||||||
//
|
|
||||||
// public String getTip() { return tip; }
|
|
||||||
// public void setTip(String tip) { this.tip = tip; }
|
|
||||||
// public String getStatus() { return status; }
|
|
||||||
// public void setStatus(String status) { this.status = status; }
|
|
||||||
// public String getMessage() { return message; }
|
|
||||||
// public void setMessage(String message) { this.message = message; }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
@ -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<String, Object> 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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.StoreData;
|
||||||
import com.won.smarketing.recommend.domain.model.WeatherData;
|
|
||||||
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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 org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Python AI 팁 생성 구현체
|
* Python AI 팁 생성 구현체 (날씨 정보 제거)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service // 추가된 어노테이션
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PythonAiTipGenerator implements AiTipGenerator {
|
public class PythonAiTipGenerator implements AiTipGenerator {
|
||||||
|
|
||||||
@ -30,22 +31,21 @@ public class PythonAiTipGenerator implements AiTipGenerator {
|
|||||||
private int timeout;
|
private int timeout;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateTip(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
|
public String generateTip(StoreData storeData, String additionalRequirement) {
|
||||||
try {
|
try {
|
||||||
log.debug("Python AI 서비스 호출: store={}, weather={}도",
|
log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName());
|
||||||
storeData.getStoreName(), weatherData.getTemperature());
|
|
||||||
|
|
||||||
// Python AI 서비스 사용 가능 여부 확인
|
// Python AI 서비스 사용 가능 여부 확인
|
||||||
if (isPythonServiceAvailable()) {
|
if (isPythonServiceAvailable()) {
|
||||||
return callPythonAiService(storeData, weatherData, additionalRequirement);
|
return callPythonAiService(storeData, additionalRequirement);
|
||||||
} else {
|
} else {
|
||||||
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
|
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
|
||||||
return createFallbackTip(storeData, weatherData, additionalRequirement);
|
return createFallbackTip(storeData, additionalRequirement);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
|
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");
|
return !pythonAiServiceApiKey.equals("dummy-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String callPythonAiService(StoreData storeData, WeatherData weatherData, String additionalRequirement) {
|
private String callPythonAiService(StoreData storeData, String additionalRequirement) {
|
||||||
try {
|
try {
|
||||||
|
// Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달)
|
||||||
Map<String, Object> requestData = Map.of(
|
Map<String, Object> requestData = Map.of(
|
||||||
"store_name", storeData.getStoreName(),
|
"store_name", storeData.getStoreName(),
|
||||||
"business_type", storeData.getBusinessType(),
|
"business_type", storeData.getBusinessType(),
|
||||||
"location", storeData.getLocation(),
|
"location", storeData.getLocation(),
|
||||||
"temperature", weatherData.getTemperature(),
|
|
||||||
"weather_condition", weatherData.getCondition(),
|
|
||||||
"humidity", weatherData.getHumidity(),
|
|
||||||
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
|
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
|
||||||
);
|
);
|
||||||
|
|
||||||
|
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
|
||||||
|
|
||||||
PythonAiResponse response = webClient
|
PythonAiResponse response = webClient
|
||||||
.post()
|
.post()
|
||||||
.uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip")
|
.uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip")
|
||||||
@ -77,49 +77,50 @@ public class PythonAiTipGenerator implements AiTipGenerator {
|
|||||||
.block();
|
.block();
|
||||||
|
|
||||||
if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) {
|
if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) {
|
||||||
|
log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length());
|
||||||
return response.getTip();
|
return response.getTip();
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
|
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();
|
String businessType = storeData.getBusinessType();
|
||||||
double temperature = weatherData.getTemperature();
|
|
||||||
String condition = weatherData.getCondition();
|
|
||||||
String storeName = storeData.getStoreName();
|
String storeName = storeData.getStoreName();
|
||||||
|
String location = storeData.getLocation();
|
||||||
|
|
||||||
// 추가 요청사항이 있는 경우 우선 반영
|
// 추가 요청사항이 있는 경우 우선 반영
|
||||||
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
|
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
|
||||||
return String.format("%s에서 %s를 고려한 특별한 서비스로 고객들을 맞이해보세요!",
|
return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!",
|
||||||
storeName, additionalRequirement);
|
storeName, additionalRequirement);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 날씨와 업종 기반 규칙
|
// 업종별 기본 팁 생성
|
||||||
if (temperature > 25) {
|
if (businessType.contains("카페")) {
|
||||||
if (businessType.contains("카페")) {
|
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
|
||||||
return String.format("더운 날씨(%.1f도)에는 시원한 아이스 음료와 디저트로 고객들을 시원하게 만족시켜보세요!", temperature);
|
} else if (businessType.contains("음식점") || businessType.contains("식당")) {
|
||||||
} else {
|
return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName);
|
||||||
return "더운 여름날, 시원한 음료나 냉면으로 고객들에게 청량감을 선사해보세요!";
|
} else if (businessType.contains("베이커리") || businessType.contains("빵집")) {
|
||||||
}
|
return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName);
|
||||||
} else if (temperature < 10) {
|
} else if (businessType.contains("치킨") || businessType.contains("튀김")) {
|
||||||
if (businessType.contains("카페")) {
|
return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName);
|
||||||
return String.format("추운 날씨(%.1f도)에는 따뜻한 음료와 베이커리로 고객들에게 따뜻함을 전해보세요!", temperature);
|
|
||||||
} else {
|
|
||||||
return "추운 겨울날, 따뜻한 국물 요리로 고객들의 몸과 마음을 따뜻하게 해보세요!";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) 같은 날씨에 어울리는 특별한 서비스로 고객들을 맞이해보세요!",
|
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
|
||||||
storeName, temperature, condition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class PythonAiResponse {
|
private static class PythonAiResponse {
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
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.WebClient;
|
||||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ import java.time.Duration;
|
|||||||
* 매장 API 데이터 제공자 구현체
|
* 매장 API 데이터 제공자 구현체
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service // 추가된 어노테이션
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class StoreApiDataProvider implements StoreDataProvider {
|
public class StoreApiDataProvider implements StoreDataProvider {
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@ -11,7 +14,7 @@ import jakarta.persistence.*;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 JPA 엔티티
|
* 마케팅 팁 JPA 엔티티 (날씨 정보 제거)
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "marketing_tips")
|
@Table(name = "marketing_tips")
|
||||||
@ -29,20 +32,10 @@ public class MarketingTipEntity {
|
|||||||
@Column(name = "store_id", nullable = false)
|
@Column(name = "store_id", nullable = false)
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
|
|
||||||
@Column(name = "tip_content", columnDefinition = "TEXT", nullable = false)
|
@Column(name = "tip_content", nullable = false, length = 2000)
|
||||||
private String tipContent;
|
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)
|
@Column(name = "store_name", length = 200)
|
||||||
private String storeName;
|
private String storeName;
|
||||||
|
|
||||||
@ -55,4 +48,32 @@ public class MarketingTipEntity {
|
|||||||
@CreatedDate
|
@CreatedDate
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,14 +1,18 @@
|
|||||||
|
package com.won.smarketing.recommend.infrastructure.persistence;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 마케팅 팁 JPA 레포지토리
|
* 마케팅 팁 JPA 레포지토리
|
||||||
*/
|
*/
|
||||||
public interface MarketingTipJpaRepository extends JpaRepository<com.won.smarketing.recommend.entity.MarketingTipEntity, Long> {
|
@Repository
|
||||||
|
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
|
||||||
|
|
||||||
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
|
||||||
Page<com.won.smarketing.recommend.entity.MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
|
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
|
||||||
}
|
}
|
||||||
@ -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.model.MarketingTip;
|
||||||
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
||||||
import com.won.smarketing.recommend.infrastructure.persistence.MarketingTipJpaRepository;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@ -9,18 +10,18 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JPA 마케팅 팁 레포지토리 구현체
|
* 마케팅 팁 레포지토리 구현체
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class JpaMarketingTipRepository implements MarketingTipRepository {
|
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
|
||||||
|
|
||||||
private final MarketingTipJpaRepository jpaRepository;
|
private final MarketingTipJpaRepository jpaRepository;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MarketingTip save(MarketingTip marketingTip) {
|
public MarketingTip save(MarketingTip marketingTip) {
|
||||||
com.won.smarketing.recommend.entity.MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
|
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
|
||||||
com.won.smarketing.recommend.entity.MarketingTipEntity savedEntity = jpaRepository.save(entity);
|
MarketingTipEntity savedEntity = jpaRepository.save(entity);
|
||||||
return savedEntity.toDomain();
|
return savedEntity.toDomain();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
//package com.won.smarketing.recommend.presentation.controller;
|
||||||
|
//
|
||||||
|
//import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
//import org.springframework.web.bind.annotation.RestController;
|
||||||
|
//
|
||||||
|
//import java.time.LocalDateTime;
|
||||||
|
//import java.util.Map;
|
||||||
|
//
|
||||||
|
///**
|
||||||
|
// * 헬스체크 컨트롤러
|
||||||
|
// */
|
||||||
|
//@RestController
|
||||||
|
//public class HealthController {
|
||||||
|
//
|
||||||
|
// @GetMapping("/health")
|
||||||
|
// public Map<String, Object> health() {
|
||||||
|
// return Map.of(
|
||||||
|
// "status", "UP",
|
||||||
|
// "service", "ai-recommend-service",
|
||||||
|
// "timestamp", LocalDateTime.now(),
|
||||||
|
// "message", "AI 추천 서비스가 정상 동작 중입니다.",
|
||||||
|
// "features", Map.of(
|
||||||
|
// "store_integration", "매장 서비스 연동",
|
||||||
|
// "python_ai_integration", "Python AI 서비스 연동",
|
||||||
|
// "fallback_support", "Fallback 팁 생성 지원"
|
||||||
|
// )
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// } catch (Exception e) {
|
||||||
|
// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
|
||||||
|
// return createMockStoreData(storeId);
|
||||||
@ -29,8 +29,8 @@ public class RecommendationController {
|
|||||||
private final MarketingTipUseCase marketingTipUseCase;
|
private final MarketingTipUseCase marketingTipUseCase;
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "AI 마케팅 팁 생성",
|
summary = "AI 마케팅 팁 생성",
|
||||||
description = "매장 정보와 환경 데이터를 기반으로 AI 마케팅 팁을 생성합니다."
|
description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다."
|
||||||
)
|
)
|
||||||
@PostMapping("/marketing-tips")
|
@PostMapping("/marketing-tips")
|
||||||
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
|
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
|
||||||
@ -45,8 +45,8 @@ public class RecommendationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "마케팅 팁 이력 조회",
|
summary = "마케팅 팁 이력 조회",
|
||||||
description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다."
|
description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다."
|
||||||
)
|
)
|
||||||
@GetMapping("/marketing-tips")
|
@GetMapping("/marketing-tips")
|
||||||
public ResponseEntity<ApiResponse<Page<MarketingTipResponse>>> getMarketingTipHistory(
|
public ResponseEntity<ApiResponse<Page<MarketingTipResponse>>> getMarketingTipHistory(
|
||||||
@ -61,8 +61,8 @@ public class RecommendationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "마케팅 팁 상세 조회",
|
summary = "마케팅 팁 상세 조회",
|
||||||
description = "특정 마케팅 팁의 상세 정보를 조회합니다."
|
description = "특정 마케팅 팁의 상세 정보를 조회합니다."
|
||||||
)
|
)
|
||||||
@GetMapping("/marketing-tips/{tipId}")
|
@GetMapping("/marketing-tips/{tipId}")
|
||||||
public ResponseEntity<ApiResponse<MarketingTipResponse>> getMarketingTip(
|
public ResponseEntity<ApiResponse<MarketingTipResponse>> getMarketingTip(
|
||||||
|
|||||||
@ -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<String, Object> parameters;
|
|
||||||
private Map<String, Object> context; // 매장 정보, 과거 데이터 등
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -27,30 +27,12 @@ public class MarketingTipResponse {
|
|||||||
@Schema(description = "AI 생성 마케팅 팁 내용")
|
@Schema(description = "AI 생성 마케팅 팁 내용")
|
||||||
private String tipContent;
|
private String tipContent;
|
||||||
|
|
||||||
@Schema(description = "날씨 정보")
|
|
||||||
private WeatherInfo weatherInfo;
|
|
||||||
|
|
||||||
@Schema(description = "매장 정보")
|
@Schema(description = "매장 정보")
|
||||||
private StoreInfo storeInfo;
|
private StoreInfo storeInfo;
|
||||||
|
|
||||||
@Schema(description = "생성 일시")
|
@Schema(description = "생성 일시")
|
||||||
private LocalDateTime createdAt;
|
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
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -24,23 +24,23 @@ spring:
|
|||||||
port: ${REDIS_PORT:6379}
|
port: ${REDIS_PORT:6379}
|
||||||
password: ${REDIS_PASSWORD:}
|
password: ${REDIS_PASSWORD:}
|
||||||
|
|
||||||
ai:
|
|
||||||
service:
|
|
||||||
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
|
|
||||||
timeout: ${AI_SERVICE_TIMEOUT:30000}
|
|
||||||
|
|
||||||
|
|
||||||
external:
|
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:
|
store-service:
|
||||||
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
|
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:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.won.smarketing.content;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
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"
|
"com.won.smarketing.content.infrastructure.repository"
|
||||||
})
|
})
|
||||||
@EntityScan(basePackages = {
|
@EntityScan(basePackages = {
|
||||||
"com.won.smarketing.content.domain.model"
|
"com.won.smarketing.content.infrastructure.entity"
|
||||||
})
|
})
|
||||||
|
@EnableJpaAuditing
|
||||||
public class MarketingContentServiceApplication {
|
public class MarketingContentServiceApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
}
|
||||||
@ -1,14 +1,10 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
|
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java
|
||||||
package com.won.smarketing.content.domain.model;
|
package com.won.smarketing.content.domain.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
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.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -17,615 +13,151 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* 콘텐츠 도메인 모델
|
* 콘텐츠 도메인 모델
|
||||||
*
|
*
|
||||||
* 이 클래스는 마케팅 콘텐츠의 핵심 정보와 비즈니스 로직을 포함하는
|
* Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티
|
||||||
* DDD(Domain-Driven Design) 엔티티입니다.
|
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
|
||||||
*
|
* Infrastructure Layer에서 별도의 JPA 엔티티로 매핑
|
||||||
* Clean Architecture의 Domain Layer에 위치하며,
|
|
||||||
* 비즈니스 규칙과 도메인 로직을 캡슐화합니다.
|
|
||||||
*/
|
*/
|
||||||
@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
|
@Getter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
@EntityListeners(AuditingEntityListener.class)
|
|
||||||
public class Content {
|
public class Content {
|
||||||
|
|
||||||
// ==================== 기본키 및 식별자 ====================
|
// ==================== 기본키 및 식별자 ====================
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
@Column(name = "id")
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
// ==================== 콘텐츠 분류 ====================
|
// ==================== 콘텐츠 분류 ====================
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "content_type", nullable = false, length = 20)
|
|
||||||
private ContentType contentType;
|
private ContentType contentType;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
|
||||||
@Column(name = "platform", nullable = false, length = 20)
|
|
||||||
private Platform platform;
|
private Platform platform;
|
||||||
|
|
||||||
// ==================== 콘텐츠 내용 ====================
|
// ==================== 콘텐츠 내용 ====================
|
||||||
|
|
||||||
@Column(name = "title", nullable = false, length = 200)
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
|
|
||||||
private String content;
|
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
|
@Builder.Default
|
||||||
private List<String> hashtags = new ArrayList<>();
|
private List<String> 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
|
@Builder.Default
|
||||||
private List<String> images = new ArrayList<>();
|
private List<String> 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;
|
private CreationConditions creationConditions;
|
||||||
|
|
||||||
// ==================== 비즈니스 관계 ====================
|
// ==================== 매장 정보 ====================
|
||||||
|
|
||||||
@Column(name = "store_id", nullable = false)
|
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
|
|
||||||
// ==================== 홍보 기간 ====================
|
// ==================== 프로모션 기간 ====================
|
||||||
|
|
||||||
@Column(name = "promotion_start_date")
|
|
||||||
private LocalDateTime promotionStartDate;
|
private LocalDateTime promotionStartDate;
|
||||||
|
|
||||||
@Column(name = "promotion_end_date")
|
|
||||||
private LocalDateTime promotionEndDate;
|
private LocalDateTime promotionEndDate;
|
||||||
|
|
||||||
// ==================== 감사(Audit) 정보 ====================
|
// ==================== 메타데이터 ====================
|
||||||
|
|
||||||
@CreatedDate
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@LastModifiedDate
|
|
||||||
@Column(name = "updated_at")
|
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 비즈니스 로직 메서드 ====================
|
// ==================== 비즈니스 메서드 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 제목 수정
|
* 콘텐츠 제목 수정
|
||||||
*
|
* @param newTitle 새로운 제목
|
||||||
* 비즈니스 규칙:
|
|
||||||
* - 제목은 null이거나 빈 값일 수 없음
|
|
||||||
* - 200자를 초과할 수 없음
|
|
||||||
* - 발행된 콘텐츠는 제목 변경 시 상태가 DRAFT로 변경됨
|
|
||||||
*
|
|
||||||
* @param title 새로운 제목
|
|
||||||
* @throws IllegalArgumentException 제목이 유효하지 않은 경우
|
|
||||||
*/
|
*/
|
||||||
public void updateTitle(String title) {
|
public void updateTitle(String newTitle) {
|
||||||
validateTitle(title);
|
if (newTitle == null || newTitle.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("제목은 필수입니다.");
|
||||||
boolean wasPublished = isPublished();
|
|
||||||
this.title = title.trim();
|
|
||||||
|
|
||||||
// 발행된 콘텐츠의 제목이 변경되면 재검토 필요
|
|
||||||
if (wasPublished) {
|
|
||||||
this.status = ContentStatus.DRAFT;
|
|
||||||
}
|
}
|
||||||
|
this.title = newTitle.trim();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 내용 수정
|
* 콘텐츠 내용 수정
|
||||||
*
|
* @param newContent 새로운 내용
|
||||||
* 비즈니스 규칙:
|
|
||||||
* - 내용은 null이거나 빈 값일 수 없음
|
|
||||||
* - 발행된 콘텐츠는 내용 변경 시 상태가 DRAFT로 변경됨
|
|
||||||
*
|
|
||||||
* @param content 새로운 콘텐츠 내용
|
|
||||||
* @throws IllegalArgumentException 내용이 유효하지 않은 경우
|
|
||||||
*/
|
*/
|
||||||
public void updateContent(String content) {
|
public void updateContent(String newContent) {
|
||||||
validateContent(content);
|
this.content = newContent;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
boolean wasPublished = isPublished();
|
/**
|
||||||
this.content = content.trim();
|
* 프로모션 기간 설정
|
||||||
|
* @param startDate 시작일
|
||||||
// 발행된 콘텐츠의 내용이 변경되면 재검토 필요
|
* @param endDate 종료일
|
||||||
if (wasPublished) {
|
*/
|
||||||
this.status = ContentStatus.DRAFT;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 상태 변경
|
* 콘텐츠 상태 변경
|
||||||
*
|
* @param newStatus 새로운 상태
|
||||||
* 비즈니스 규칙:
|
|
||||||
* - PUBLISHED 상태로 변경시 유효성 검증 수행
|
|
||||||
* - ARCHIVED 상태에서는 PUBLISHED로만 변경 가능
|
|
||||||
*
|
|
||||||
* @param status 새로운 상태
|
|
||||||
* @throws IllegalStateException 잘못된 상태 전환인 경우
|
|
||||||
*/
|
*/
|
||||||
// public void changeStatus(ContentStatus status) {
|
public void updateStatus(ContentStatus newStatus) {
|
||||||
// validateStatusTransition(this.status, status);
|
if (newStatus == null) {
|
||||||
//
|
throw new IllegalArgumentException("상태는 필수입니다.");
|
||||||
// if (status == ContentStatus.PUBLISHED) {
|
}
|
||||||
// validateForPublication();
|
this.status = newStatus;
|
||||||
// }
|
this.updatedAt = LocalDateTime.now();
|
||||||
//
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 해시태그 추가
|
* 해시태그 추가
|
||||||
*
|
* @param hashtag 추가할 해시태그
|
||||||
* @param hashtag 추가할 해시태그 (# 없이)
|
|
||||||
*/
|
*/
|
||||||
public void addHashtag(String hashtag) {
|
public void addHashtag(String hashtag) {
|
||||||
if (hashtag != null && !hashtag.trim().isEmpty()) {
|
if (hashtag != null && !hashtag.trim().isEmpty()) {
|
||||||
String cleanHashtag = hashtag.trim().replace("#", "");
|
if (this.hashtags == null) {
|
||||||
if (!this.hashtags.contains(cleanHashtag)) {
|
this.hashtags = new ArrayList<>();
|
||||||
this.hashtags.add(cleanHashtag);
|
|
||||||
}
|
}
|
||||||
}
|
this.hashtags.add(hashtag.trim());
|
||||||
}
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
|
||||||
/**
|
|
||||||
* 해시태그 제거
|
|
||||||
*
|
|
||||||
* @param hashtag 제거할 해시태그
|
|
||||||
*/
|
|
||||||
public void removeHashtag(String hashtag) {
|
|
||||||
if (hashtag != null) {
|
|
||||||
String cleanHashtag = hashtag.trim().replace("#", "");
|
|
||||||
this.hashtags.remove(cleanHashtag);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 추가
|
* 이미지 추가
|
||||||
*
|
* @param imageUrl 추가할 이미지 URL
|
||||||
* @param imageUrl 이미지 URL
|
|
||||||
*/
|
*/
|
||||||
public void addImage(String imageUrl) {
|
public void addImage(String imageUrl) {
|
||||||
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
|
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
|
||||||
if (!this.images.contains(imageUrl.trim())) {
|
if (this.images == null) {
|
||||||
this.images.add(imageUrl.trim());
|
this.images = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
this.images.add(imageUrl.trim());
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이미지 제거
|
* 프로모션 진행 중 여부 확인
|
||||||
*
|
* @return 현재 시간이 프로모션 기간 내에 있으면 true
|
||||||
* @param imageUrl 제거할 이미지 URL
|
|
||||||
*/
|
*/
|
||||||
public void removeImage(String imageUrl) {
|
public boolean isPromotionActive() {
|
||||||
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() {
|
|
||||||
if (promotionStartDate == null || promotionEndDate == null) {
|
if (promotionStartDate == null || promotionEndDate == null) {
|
||||||
return 0.0;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate);
|
||||||
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 필수 정보가 모두 입력되어 있으면 true
|
||||||
* @return 남은 일수 (음수면 0)
|
|
||||||
*/
|
*/
|
||||||
public long calculateRemainingDays() {
|
public boolean canBePublished() {
|
||||||
if (promotionEndDate == null) {
|
return title != null && !title.trim().isEmpty()
|
||||||
return 0L;
|
&& contentType != null
|
||||||
}
|
&& platform != null
|
||||||
|
&& storeId != null;
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
if (now.isAfter(promotionEndDate)) {
|
|
||||||
return 0L;
|
|
||||||
}
|
|
||||||
|
|
||||||
return java.time.Duration.between(now, promotionEndDate).toDays();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 팩토리 메서드 ====================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<String> 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
|
|
||||||
);
|
|
||||||
*/
|
|
||||||
@ -1,53 +1,51 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java
|
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java
|
||||||
package com.won.smarketing.content.domain.model;
|
package com.won.smarketing.content.domain.model;
|
||||||
|
|
||||||
import jakarta.persistence.Embeddable;
|
import lombok.EqualsAndHashCode;
|
||||||
import lombok.AccessLevel;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 ID 값 객체
|
* 콘텐츠 ID 값 객체
|
||||||
* Clean Architecture의 Domain Layer에 위치하는 식별자
|
* Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리
|
||||||
*/
|
*/
|
||||||
@Embeddable
|
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
@RequiredArgsConstructor
|
||||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
@EqualsAndHashCode
|
||||||
public class ContentId {
|
public class ContentId {
|
||||||
|
|
||||||
private Long value;
|
private final Long value;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ContentId 생성 팩토리 메서드
|
* Long 값으로부터 ContentId 생성
|
||||||
* @param value ID 값
|
* @param value ID 값
|
||||||
* @return ContentId 인스턴스
|
* @return ContentId 인스턴스
|
||||||
*/
|
*/
|
||||||
public static ContentId of(Long value) {
|
public static ContentId of(Long value) {
|
||||||
if (value == null || value <= 0) {
|
if (value == null || value <= 0) {
|
||||||
throw new IllegalArgumentException("ContentId 값은 양수여야 합니다.");
|
throw new IllegalArgumentException("ContentId는 양수여야 합니다.");
|
||||||
}
|
}
|
||||||
return new ContentId(value);
|
return new ContentId(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public boolean equals(Object o) {
|
* 새로운 ContentId 생성 (ID가 없는 경우)
|
||||||
if (this == o) return true;
|
* @return null 값을 가진 ContentId
|
||||||
if (o == null || getClass() != o.getClass()) return false;
|
*/
|
||||||
ContentId contentId = (ContentId) o;
|
public static ContentId newId() {
|
||||||
return Objects.equals(value, contentId.value);
|
return new ContentId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public int hashCode() {
|
* ID 값 존재 여부 확인
|
||||||
return Objects.hash(value);
|
* @return ID가 null이 아니면 true
|
||||||
|
*/
|
||||||
|
public boolean hasValue() {
|
||||||
|
return value != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return "ContentId{" + value + '}';
|
return "ContentId{" + "value=" + value + '}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
|
// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java
|
||||||
package com.won.smarketing.content.domain.model;
|
package com.won.smarketing.content.domain.model;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
@ -12,57 +11,48 @@ import java.time.LocalDate;
|
|||||||
/**
|
/**
|
||||||
* 콘텐츠 생성 조건 도메인 모델
|
* 콘텐츠 생성 조건 도메인 모델
|
||||||
* Clean Architecture의 Domain Layer에 위치하는 값 객체
|
* Clean Architecture의 Domain Layer에 위치하는 값 객체
|
||||||
|
*
|
||||||
|
* JPA 애노테이션을 제거하여 순수 도메인 모델로 유지
|
||||||
|
* Infrastructure Layer의 JPA 엔티티는 별도로 관리
|
||||||
*/
|
*/
|
||||||
@Entity
|
|
||||||
@Table(name = "contents_conditions")
|
|
||||||
@Getter
|
@Getter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Builder
|
@Builder
|
||||||
public class CreationConditions {
|
public class CreationConditions {
|
||||||
|
|
||||||
@Id
|
private String 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 category;
|
private String category;
|
||||||
|
|
||||||
@Column(name = "requirement", columnDefinition = "TEXT")
|
|
||||||
private String requirement;
|
private String requirement;
|
||||||
|
|
||||||
@Column(name = "tone_and_manner", length = 100)
|
|
||||||
private String toneAndManner;
|
private String toneAndManner;
|
||||||
|
|
||||||
@Column(name = "emotion_intensity", length = 100)
|
|
||||||
private String emotionIntensity;
|
private String emotionIntensity;
|
||||||
|
|
||||||
@Column(name = "event_name", length = 200)
|
|
||||||
private String eventName;
|
private String eventName;
|
||||||
|
|
||||||
@Column(name = "start_date")
|
|
||||||
private LocalDate startDate;
|
private LocalDate startDate;
|
||||||
|
|
||||||
@Column(name = "end_date")
|
|
||||||
private LocalDate endDate;
|
private LocalDate endDate;
|
||||||
|
|
||||||
@Column(name = "photo_style", length = 100)
|
|
||||||
private String photoStyle;
|
private String photoStyle;
|
||||||
|
|
||||||
@Column(name = "promotionType", length = 100)
|
|
||||||
private String promotionType;
|
private String promotionType;
|
||||||
|
|
||||||
public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
|
public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) {
|
||||||
}
|
}
|
||||||
// /**
|
|
||||||
// * 콘텐츠와의 연관관계 설정
|
/**
|
||||||
// * @param content 연관된 콘텐츠
|
* 이벤트 기간 유효성 검증
|
||||||
// */
|
* @return 시작일이 종료일보다 이전이거나 같으면 true
|
||||||
// public void setContent(Content content) {
|
*/
|
||||||
// this.content = content;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -11,6 +11,7 @@ import java.time.LocalDate;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 생성 조건 JPA 엔티티
|
* 콘텐츠 생성 조건 JPA 엔티티
|
||||||
|
* Infrastructure Layer에서 데이터베이스 매핑을 담당
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "content_conditions")
|
@Table(name = "content_conditions")
|
||||||
@ -50,9 +51,34 @@ public class ContentConditionsJpaEntity {
|
|||||||
@Column(name = "photo_style", length = 100)
|
@Column(name = "photo_style", length = 100)
|
||||||
private String photoStyle;
|
private String photoStyle;
|
||||||
|
|
||||||
@Column(name = "target_audience", length = 200)
|
|
||||||
private String targetAudience;
|
|
||||||
|
|
||||||
@Column(name = "promotion_type", length = 100)
|
@Column(name = "promotion_type", length = 100)
|
||||||
private String promotionType;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -10,6 +10,7 @@ import org.springframework.data.annotation.LastModifiedDate;
|
|||||||
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 JPA 엔티티
|
* 콘텐츠 JPA 엔티티
|
||||||
@ -37,6 +38,12 @@ public class ContentJpaEntity {
|
|||||||
@Column(name = "title", length = 500)
|
@Column(name = "title", length = 500)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
@Column(name = "PromotionStartDate")
|
||||||
|
private LocalDateTime PromotionStartDate;
|
||||||
|
|
||||||
|
@Column(name = "PromotionEndDate")
|
||||||
|
private LocalDateTime PromotionEndDate;
|
||||||
|
|
||||||
@Column(name = "content", columnDefinition = "TEXT")
|
@Column(name = "content", columnDefinition = "TEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java
|
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java
|
||||||
package com.won.smarketing.content.infrastructure.external;
|
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.Platform;
|
||||||
import com.won.smarketing.content.domain.model.CreationConditions;
|
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -21,105 +23,73 @@ public class ClaudeAiContentGenerator implements AiContentGenerator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SNS 콘텐츠 생성
|
* SNS 콘텐츠 생성
|
||||||
* Claude AI API를 호출하여 SNS 게시물을 생성합니다.
|
|
||||||
*
|
|
||||||
* @param title 제목
|
|
||||||
* @param category 카테고리
|
|
||||||
* @param platform 플랫폼
|
|
||||||
* @param conditions 생성 조건
|
|
||||||
* @return 생성된 콘텐츠 텍스트
|
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions) {
|
public String generateSnsContent(SnsContentCreateRequest request) {
|
||||||
try {
|
try {
|
||||||
// Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용)
|
String prompt = buildContentPrompt(request);
|
||||||
String prompt = buildContentPrompt(title, category, platform, conditions);
|
return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform()));
|
||||||
|
|
||||||
// TODO: 실제 Claude AI API 호출
|
|
||||||
// 현재는 더미 데이터 반환
|
|
||||||
return generateDummySnsContent(title, platform);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), 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
|
@Override
|
||||||
public List<String> generateHashtags(String content, Platform platform) {
|
public List<String> generateHashtags(String content, Platform platform) {
|
||||||
try {
|
try {
|
||||||
// TODO: 실제 Claude AI API 호출하여 해시태그 생성
|
|
||||||
// 현재는 더미 데이터 반환
|
|
||||||
return generateDummyHashtags(platform);
|
return generateDummyHashtags(platform);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("해시태그 생성 실패: {}", e.getMessage(), e);
|
log.error("해시태그 생성 실패: {}", e.getMessage(), e);
|
||||||
return Arrays.asList("#맛집", "#신메뉴", "#추천");
|
return generateFallbackHashtags();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private String buildContentPrompt(SnsContentCreateRequest request) {
|
||||||
* AI 프롬프트 생성
|
|
||||||
*/
|
|
||||||
private String buildContentPrompt(String title, String category, Platform platform, CreationConditions conditions) {
|
|
||||||
StringBuilder prompt = new StringBuilder();
|
StringBuilder prompt = new StringBuilder();
|
||||||
prompt.append("다음 조건에 맞는 ").append(platform.getDisplayName()).append(" 게시물을 작성해주세요:\n");
|
prompt.append("제목: ").append(request.getTitle()).append("\n");
|
||||||
prompt.append("제목: ").append(title).append("\n");
|
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
|
||||||
prompt.append("카테고리: ").append(category).append("\n");
|
prompt.append("플랫폼: ").append(request.getPlatform()).append("\n");
|
||||||
|
|
||||||
if (conditions.getRequirement() != null) {
|
if (request.getRequirement() != null) {
|
||||||
prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n");
|
prompt.append("요구사항: ").append(request.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");
|
||||||
if (conditions.getEmotionIntensity() != null) {
|
|
||||||
prompt.append("감정 강도: ").append(conditions.getEmotionIntensity()).append("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return prompt.toString();
|
return prompt.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 더미 SNS 콘텐츠 생성 (개발용)
|
|
||||||
*/
|
|
||||||
private String generateDummySnsContent(String title, Platform platform) {
|
private String generateDummySnsContent(String title, Platform platform) {
|
||||||
switch (platform) {
|
String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" +
|
||||||
case INSTAGRAM:
|
"저희 매장에서 특별한 경험을 만나보세요.\n" +
|
||||||
return String.format("🎉 %s\n\n맛있는 순간을 놓치지 마세요! 새로운 맛의 경험이 여러분을 기다리고 있어요. 따뜻한 분위기에서 즐기는 특별한 시간을 만들어보세요.\n\n📍 지금 바로 방문해보세요!", title);
|
"고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n";
|
||||||
case NAVER_BLOG:
|
|
||||||
return String.format("안녕하세요! 오늘은 %s에 대해 소개해드리려고 해요.\n\n정성스럽게 준비한 새로운 메뉴로 고객 여러분께 더 나은 경험을 선사하고 싶습니다. 많은 관심과 사랑 부탁드려요!", title);
|
if (platform == Platform.INSTAGRAM) {
|
||||||
default:
|
return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸";
|
||||||
return String.format("%s - 새로운 경험을 만나보세요!", title);
|
} else {
|
||||||
|
return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 더미 해시태그 생성 (개발용)
|
|
||||||
*/
|
|
||||||
private List<String> 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) {
|
private String generateFallbackContent(String title, Platform platform) {
|
||||||
return String.format("🎉 %s\n\n새로운 소식을 전해드립니다. 많은 관심 부탁드려요!", title);
|
return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> generateDummyHashtags(Platform platform) {
|
||||||
|
if (platform == Platform.INSTAGRAM) {
|
||||||
|
return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램");
|
||||||
|
} else {
|
||||||
|
return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> generateFallbackHashtags() {
|
||||||
|
return Arrays.asList("#소상공인", "#마케팅", "#홍보");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java
|
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java
|
||||||
package com.won.smarketing.content.infrastructure.external;
|
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.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@ -19,23 +20,20 @@ import java.util.Map;
|
|||||||
public class ClaudeAiPosterGenerator implements AiPosterGenerator {
|
public class ClaudeAiPosterGenerator implements AiPosterGenerator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포스터 이미지 생성
|
* 포스터 생성
|
||||||
* Claude AI API를 호출하여 홍보 포스터를 생성합니다.
|
|
||||||
*
|
*
|
||||||
* @param title 제목
|
* @param request 포스터 생성 요청
|
||||||
* @param category 카테고리
|
|
||||||
* @param conditions 생성 조건
|
|
||||||
* @return 생성된 포스터 이미지 URL
|
* @return 생성된 포스터 이미지 URL
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String generatePoster(String title, String category, CreationConditions conditions) {
|
public String generatePoster(PosterContentCreateRequest request) {
|
||||||
try {
|
try {
|
||||||
// Claude AI API 호출 로직 (실제 구현에서는 HTTP 클라이언트를 사용)
|
// Claude AI API 호출 로직
|
||||||
String prompt = buildPosterPrompt(title, category, conditions);
|
String prompt = buildPosterPrompt(request);
|
||||||
|
|
||||||
// TODO: 실제 Claude AI API 호출
|
// TODO: 실제 Claude AI API 호출
|
||||||
// 현재는 더미 데이터 반환
|
// 현재는 더미 데이터 반환
|
||||||
return generateDummyPosterUrl(title);
|
return generateDummyPosterUrl(request.getTitle());
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
|
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
|
||||||
@ -44,75 +42,45 @@ public class ClaudeAiPosterGenerator implements AiPosterGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포스터 다양한 사이즈 생성
|
* 다양한 사이즈의 포스터 생성
|
||||||
* 원본 포스터를 기반으로 다양한 사이즈의 포스터를 생성합니다.
|
|
||||||
*
|
*
|
||||||
* @param originalImage 원본 이미지 URL
|
* @param baseImage 기본 이미지
|
||||||
* @return 사이즈별 이미지 URL 맵
|
* @return 사이즈별 포스터 URL 맵
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Map<String, String> generatePosterSizes(String originalImage) {
|
public Map<String, String> generatePosterSizes(String baseImage) {
|
||||||
try {
|
Map<String, String> sizes = new HashMap<>();
|
||||||
// TODO: 실제 이미지 리사이징 API 호출
|
|
||||||
// 현재는 더미 데이터 반환
|
|
||||||
return generateDummyPosterSizes(originalImage);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
// 다양한 사이즈 생성 (더미 구현)
|
||||||
log.error("포스터 사이즈 생성 실패: {}", e.getMessage(), e);
|
sizes.put("instagram_square", baseImage + "_1080x1080.jpg");
|
||||||
return new HashMap<>();
|
sizes.put("instagram_story", baseImage + "_1080x1920.jpg");
|
||||||
}
|
sizes.put("facebook_post", baseImage + "_1200x630.jpg");
|
||||||
|
sizes.put("a4_poster", baseImage + "_2480x3508.jpg");
|
||||||
|
|
||||||
|
return sizes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private String buildPosterPrompt(PosterContentCreateRequest request) {
|
||||||
* AI 포스터 프롬프트 생성
|
|
||||||
*/
|
|
||||||
private String buildPosterPrompt(String title, String category, CreationConditions conditions) {
|
|
||||||
StringBuilder prompt = new StringBuilder();
|
StringBuilder prompt = new StringBuilder();
|
||||||
prompt.append("다음 조건에 맞는 홍보 포스터를 생성해주세요:\n");
|
prompt.append("포스터 제목: ").append(request.getTitle()).append("\n");
|
||||||
prompt.append("제목: ").append(title).append("\n");
|
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
|
||||||
prompt.append("카테고리: ").append(category).append("\n");
|
|
||||||
|
|
||||||
if (conditions.getPhotoStyle() != null) {
|
if (request.getRequirement() != null) {
|
||||||
prompt.append("사진 스타일: ").append(conditions.getPhotoStyle()).append("\n");
|
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
|
||||||
}
|
}
|
||||||
if (conditions.getRequirement() != null) {
|
|
||||||
prompt.append("요구사항: ").append(conditions.getRequirement()).append("\n");
|
if (request.getToneAndManner() != null) {
|
||||||
}
|
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
|
||||||
if (conditions.getToneAndManner() != null) {
|
|
||||||
prompt.append("톤앤매너: ").append(conditions.getToneAndManner()).append("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return prompt.toString();
|
return prompt.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 더미 포스터 URL 생성 (개발용)
|
|
||||||
*/
|
|
||||||
private String generateDummyPosterUrl(String title) {
|
private String generateDummyPosterUrl(String title) {
|
||||||
return String.format("https://example.com/posters/%s-poster.jpg",
|
return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg";
|
||||||
title.replaceAll("\\s+", "-").toLowerCase());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 더미 포스터 사이즈별 URL 생성 (개발용)
|
|
||||||
*/
|
|
||||||
private Map<String, String> generateDummyPosterSizes(String originalImage) {
|
|
||||||
Map<String, String> 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() {
|
private String generateFallbackPosterUrl() {
|
||||||
return "https://example.com/posters/default-poster.jpg";
|
return "https://dummy-ai-service.com/posters/fallback.jpg";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java
|
||||||
package com.won.smarketing.content.infrastructure.mapper;
|
package com.won.smarketing.content.infrastructure.mapper;
|
||||||
|
|
||||||
import com.won.smarketing.content.domain.model.*;
|
import com.won.smarketing.content.domain.model.*;
|
||||||
@ -14,6 +15,7 @@ import java.util.List;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 도메인-엔티티 매퍼
|
* 콘텐츠 도메인-엔티티 매퍼
|
||||||
|
* Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당
|
||||||
*
|
*
|
||||||
* @author smarketing-team
|
* @author smarketing-team
|
||||||
* @version 1.0
|
* @version 1.0
|
||||||
@ -26,7 +28,7 @@ public class ContentMapper {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 도메인 모델을 JPA 엔티티로 변환합니다.
|
* 도메인 모델을 JPA 엔티티로 변환
|
||||||
*
|
*
|
||||||
* @param content 도메인 콘텐츠
|
* @param content 도메인 콘텐츠
|
||||||
* @return JPA 엔티티
|
* @return JPA 엔티티
|
||||||
@ -37,32 +39,30 @@ public class ContentMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ContentJpaEntity entity = new ContentJpaEntity();
|
ContentJpaEntity entity = new ContentJpaEntity();
|
||||||
|
|
||||||
|
// 기본 필드 매핑
|
||||||
if (content.getId() != null) {
|
if (content.getId() != null) {
|
||||||
entity.setId(content.getId());
|
entity.setId(content.getId());
|
||||||
}
|
}
|
||||||
entity.setStoreId(content.getStoreId());
|
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.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null);
|
||||||
entity.setTitle(content.getTitle());
|
entity.setTitle(content.getTitle());
|
||||||
entity.setContent(content.getContent());
|
entity.setContent(content.getContent());
|
||||||
entity.setHashtags(convertListToJson(content.getHashtags()));
|
entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT");
|
||||||
entity.setImages(convertListToJson(content.getImages()));
|
entity.setPromotionStartDate(content.getPromotionStartDate());
|
||||||
entity.setStatus(content.getStatus().name());
|
entity.setPromotionEndDate(content.getPromotionEndDate());
|
||||||
entity.setCreatedAt(content.getCreatedAt());
|
entity.setCreatedAt(content.getCreatedAt());
|
||||||
entity.setUpdatedAt(content.getUpdatedAt());
|
entity.setUpdatedAt(content.getUpdatedAt());
|
||||||
|
|
||||||
// 조건 정보 매핑
|
// 컬렉션 필드를 JSON으로 변환
|
||||||
|
entity.setHashtags(convertListToJson(content.getHashtags()));
|
||||||
|
entity.setImages(convertListToJson(content.getImages()));
|
||||||
|
|
||||||
|
// 생성 조건 정보 매핑
|
||||||
if (content.getCreationConditions() != null) {
|
if (content.getCreationConditions() != null) {
|
||||||
ContentConditionsJpaEntity conditionsEntity = new ContentConditionsJpaEntity();
|
ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions());
|
||||||
conditionsEntity.setContent(entity);
|
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);
|
entity.setConditions(conditionsEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,50 +70,74 @@ public class ContentMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JPA 엔티티를 도메인 모델로 변환합니다.
|
* JPA 엔티티를 도메인 모델로 변환
|
||||||
*
|
*
|
||||||
* @param entity JPA 엔티티
|
* @param entity JPA 엔티티
|
||||||
* @return 도메인 콘텐츠
|
* @return 도메인 모델
|
||||||
*/
|
*/
|
||||||
public Content toDomain(ContentJpaEntity entity) {
|
public Content toDomain(ContentJpaEntity entity) {
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
CreationConditions conditions = null;
|
return Content.builder()
|
||||||
if (entity.getConditions() != null) {
|
.id(entity.getId())
|
||||||
conditions = new CreationConditions(
|
.storeId(entity.getStoreId())
|
||||||
entity.getConditions().getCategory(),
|
.contentType(parseContentType(entity.getContentType()))
|
||||||
entity.getConditions().getRequirement(),
|
.platform(parsePlatform(entity.getPlatform()))
|
||||||
entity.getConditions().getToneAndManner(),
|
.title(entity.getTitle())
|
||||||
entity.getConditions().getEmotionIntensity(),
|
.content(entity.getContent())
|
||||||
entity.getConditions().getEventName(),
|
.hashtags(convertJsonToList(entity.getHashtags()))
|
||||||
entity.getConditions().getStartDate(),
|
.images(convertJsonToList(entity.getImages()))
|
||||||
entity.getConditions().getEndDate(),
|
.status(parseContentStatus(entity.getStatus()))
|
||||||
entity.getConditions().getPhotoStyle(),
|
.promotionStartDate(entity.getPromotionStartDate())
|
||||||
// entity.getConditions().getTargetAudience(),
|
.promotionEndDate(entity.getPromotionEndDate())
|
||||||
entity.getConditions().getPromotionType()
|
.creationConditions(mapToConditionsDomain(entity.getConditions()))
|
||||||
);
|
.createdAt(entity.getCreatedAt())
|
||||||
}
|
.updatedAt(entity.getUpdatedAt())
|
||||||
|
.build();
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<String> list) {
|
private String convertListToJson(List<String> list) {
|
||||||
if (list == null || list.isEmpty()) {
|
if (list == null || list.isEmpty()) {
|
||||||
@ -128,7 +152,7 @@ public class ContentMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 문자열을 List로 변환합니다.
|
* JSON 문자열을 List로 변환
|
||||||
*/
|
*/
|
||||||
private List<String> convertJsonToList(String json) {
|
private List<String> convertJsonToList(String json) {
|
||||||
if (json == null || json.trim().isEmpty()) {
|
if (json == null || json.trim().isEmpty()) {
|
||||||
@ -141,4 +165,49 @@ public class ContentMapper {
|
|||||||
return Collections.emptyList();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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.ContentType;
|
||||||
import com.won.smarketing.content.domain.model.Platform;
|
import com.won.smarketing.content.domain.model.Platform;
|
||||||
import com.won.smarketing.content.domain.repository.ContentRepository;
|
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.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JPA를 활용한 콘텐츠 리포지토리 구현체
|
* JPA를 활용한 콘텐츠 리포지토리 구현체
|
||||||
* Clean Architecture의 Infrastructure Layer에 위치
|
* Clean Architecture의 Infrastructure Layer에 위치
|
||||||
|
* JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class JpaContentRepository implements ContentRepository {
|
public class JpaContentRepository implements ContentRepository {
|
||||||
|
|
||||||
private final JpaContentRepositoryInterface jpaRepository;
|
private final JpaContentRepositoryInterface jpaRepository;
|
||||||
|
private final ContentMapper contentMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 콘텐츠 저장
|
* 콘텐츠 저장
|
||||||
* @param content 저장할 콘텐츠
|
* @param content 저장할 도메인 콘텐츠
|
||||||
* @return 저장된 콘텐츠
|
* @return 저장된 도메인 콘텐츠
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Content save(Content content) {
|
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로 콘텐츠 조회
|
* ID로 콘텐츠 조회
|
||||||
* @param id 콘텐츠 ID
|
* @param id 콘텐츠 ID
|
||||||
* @return 조회된 콘텐츠
|
* @return 조회된 도메인 콘텐츠
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Optional<Content> findById(ContentId id) {
|
public Optional<Content> 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 contentType 콘텐츠 타입
|
||||||
* @param platform 플랫폼
|
* @param platform 플랫폼
|
||||||
* @param period 기간
|
* @param period 기간 (현재는 사용하지 않음)
|
||||||
* @param sortBy 정렬 기준
|
* @param sortBy 정렬 기준 (현재는 사용하지 않음)
|
||||||
* @return 콘텐츠 목록
|
* @return 도메인 콘텐츠 목록
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy) {
|
public List<Content> 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<ContentJpaEntity> entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null);
|
||||||
|
|
||||||
|
return entities.stream()
|
||||||
|
.map(contentMapper::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 진행 중인 콘텐츠 목록 조회
|
* 진행 중인 콘텐츠 목록 조회
|
||||||
* @param period 기간
|
* @param period 기간 (현재는 사용하지 않음)
|
||||||
* @return 진행 중인 콘텐츠 목록
|
* @return 진행 중인 도메인 콘텐츠 목록
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<Content> findOngoingContents(String period) {
|
public List<Content> findOngoingContents(String period) {
|
||||||
return jpaRepository.findOngoingContents(period);
|
log.debug("Finding ongoing contents");
|
||||||
|
|
||||||
|
List<ContentJpaEntity> entities = jpaRepository.findOngoingContents();
|
||||||
|
|
||||||
|
return entities.stream()
|
||||||
|
.map(contentMapper::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -71,6 +108,40 @@ public class JpaContentRepository implements ContentRepository {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void deleteById(ContentId id) {
|
public void deleteById(ContentId id) {
|
||||||
|
log.debug("Deleting content by ID: {}", id.getValue());
|
||||||
|
|
||||||
jpaRepository.deleteById(id.getValue());
|
jpaRepository.deleteById(id.getValue());
|
||||||
|
|
||||||
|
log.debug("Content deleted successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID로 콘텐츠 목록 조회 (추가 메서드)
|
||||||
|
* @param storeId 매장 ID
|
||||||
|
* @return 도메인 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
public List<Content> findByStoreId(Long storeId) {
|
||||||
|
log.debug("Finding contents by store ID: {}", storeId);
|
||||||
|
|
||||||
|
List<ContentJpaEntity> entities = jpaRepository.findByStoreId(storeId);
|
||||||
|
|
||||||
|
return entities.stream()
|
||||||
|
.map(contentMapper::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 타입으로 조회 (추가 메서드)
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @return 도메인 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
public List<Content> findByContentType(ContentType contentType) {
|
||||||
|
log.debug("Finding contents by type: {}", contentType);
|
||||||
|
|
||||||
|
List<ContentJpaEntity> entities = jpaRepository.findByContentType(contentType.name());
|
||||||
|
|
||||||
|
return entities.stream()
|
||||||
|
.map(contentMapper::toDomain)
|
||||||
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,9 +1,7 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java
|
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java
|
||||||
package com.won.smarketing.content.infrastructure.repository;
|
package com.won.smarketing.content.infrastructure.repository;
|
||||||
|
|
||||||
import com.won.smarketing.content.domain.model.Content;
|
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
|
||||||
import com.won.smarketing.content.domain.model.ContentType;
|
|
||||||
import com.won.smarketing.content.domain.model.Platform;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
@ -13,37 +11,77 @@ import java.util.List;
|
|||||||
/**
|
/**
|
||||||
* Spring Data JPA 콘텐츠 리포지토리 인터페이스
|
* Spring Data JPA 콘텐츠 리포지토리 인터페이스
|
||||||
* Clean Architecture의 Infrastructure Layer에 위치
|
* Clean Architecture의 Infrastructure Layer에 위치
|
||||||
|
* JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근
|
||||||
*/
|
*/
|
||||||
public interface JpaContentRepositoryInterface extends JpaRepository<Content, Long> {
|
public interface JpaContentRepositoryInterface extends JpaRepository<ContentJpaEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID로 콘텐츠 목록 조회
|
||||||
|
* @param storeId 매장 ID
|
||||||
|
* @return 콘텐츠 엔티티 목록
|
||||||
|
*/
|
||||||
|
List<ContentJpaEntity> findByStoreId(Long storeId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 타입으로 조회
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @return 콘텐츠 엔티티 목록
|
||||||
|
*/
|
||||||
|
List<ContentJpaEntity> findByContentType(String contentType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫폼으로 조회
|
||||||
|
* @param platform 플랫폼
|
||||||
|
* @return 콘텐츠 엔티티 목록
|
||||||
|
*/
|
||||||
|
List<ContentJpaEntity> findByPlatform(String platform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태로 조회
|
||||||
|
* @param status 상태
|
||||||
|
* @return 콘텐츠 엔티티 목록
|
||||||
|
*/
|
||||||
|
List<ContentJpaEntity> 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 " +
|
"(:contentType IS NULL OR c.contentType = :contentType) AND " +
|
||||||
"(:platform IS NULL OR c.platform = :platform) AND " +
|
"(:platform IS NULL OR c.platform = :platform) AND " +
|
||||||
"(:period IS NULL OR " +
|
"(:status IS NULL OR c.status = :status) " +
|
||||||
" (:period = 'week' AND c.createdAt >= CURRENT_DATE - 7) OR " +
|
"ORDER BY c.createdAt DESC")
|
||||||
" (:period = 'month' AND c.createdAt >= CURRENT_DATE - 30) OR " +
|
List<ContentJpaEntity> findByFilters(@Param("contentType") String contentType,
|
||||||
" (:period = 'year' AND c.createdAt >= CURRENT_DATE - 365)) " +
|
@Param("platform") String platform,
|
||||||
"ORDER BY " +
|
@Param("status") String status);
|
||||||
"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<Content> findByFilters(@Param("contentType") ContentType contentType,
|
|
||||||
@Param("platform") Platform platform,
|
|
||||||
@Param("period") String period,
|
|
||||||
@Param("sortBy") String sortBy);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 진행 중인 콘텐츠 목록 조회
|
* 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠)
|
||||||
|
* @return 진행 중인 콘텐츠 엔티티 목록
|
||||||
*/
|
*/
|
||||||
@Query("SELECT c FROM Content c WHERE " +
|
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
|
||||||
"c.status IN ('PUBLISHED', 'SCHEDULED') AND " +
|
"c.status IN ('PUBLISHED', 'SCHEDULED') " +
|
||||||
"(: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 c.createdAt DESC")
|
"ORDER BY c.createdAt DESC")
|
||||||
List<Content> findOngoingContents(@Param("period") String period);
|
List<ContentJpaEntity> findOngoingContents();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 ID와 콘텐츠 타입으로 조회
|
||||||
|
* @param storeId 매장 ID
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @return 콘텐츠 엔티티 목록
|
||||||
|
*/
|
||||||
|
List<ContentJpaEntity> 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<ContentJpaEntity> findRecentContentsByStoreId(@Param("storeId") Long storeId);
|
||||||
}
|
}
|
||||||
@ -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<ContentJpaEntity, Long> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 조건으로 콘텐츠를 조회합니다.
|
|
||||||
*
|
|
||||||
* @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<ContentJpaEntity> 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<ContentJpaEntity> findOngoingContents(@Param("period") String period);
|
|
||||||
}
|
|
||||||
@ -17,21 +17,17 @@ spring:
|
|||||||
hibernate:
|
hibernate:
|
||||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
format_sql: true
|
format_sql: true
|
||||||
ai:
|
data:
|
||||||
service:
|
redis:
|
||||||
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
|
host: ${REDIS_HOST:localhost}
|
||||||
timeout: ${AI_SERVICE_TIMEOUT:30000}
|
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:
|
jwt:
|
||||||
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
|
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
|
||||||
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
|
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
|
||||||
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
|
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
com.won.smarketing.content: ${LOG_LEVEL:DEBUG}
|
com.won.smarketing: ${LOG_LEVEL:DEBUG}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user