modify: folder - java/python

This commit is contained in:
unknown
2025-06-11 14:24:26 +09:00
parent a86d2e47ce
commit 7813f934b9
126 changed files with 1856 additions and 129 deletions
@@ -0,0 +1,20 @@
package com.won.smarketing.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* AI 추천 서비스 메인 애플리케이션 클래스
* Clean Architecture 패턴을 적용한 AI 마케팅 추천 서비스
*/
@SpringBootApplication(scanBasePackages = {"com.won.smarketing.recommend", "com.won.smarketing.common"})
@EntityScan(basePackages = {"com.won.smarketing.recommend.infrastructure.entity"})
@EnableJpaRepositories(basePackages = {"com.won.smarketing.recommend.infrastructure.repository"})
public class AIRecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AIRecommendServiceApplication.class, args);
}
}
@@ -0,0 +1,83 @@
package com.won.smarketing.recommend.application.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.TipId;
import com.won.smarketing.recommend.domain.model.WeatherData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.domain.service.WeatherDataProvider;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 마케팅 팁 서비스 구현체
* AI 기반 마케팅 팁 생성 및 저장 기능 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MarketingTipService implements MarketingTipUseCase {
private final MarketingTipRepository marketingTipRepository;
private final StoreDataProvider storeDataProvider;
private final WeatherDataProvider weatherDataProvider;
private final AiTipGenerator aiTipGenerator;
/**
* AI 마케팅 팁 생성
*
* @param request 마케팅 팁 생성 요청
* @return 생성된 마케팅 팁 응답
*/
@Override
@Transactional
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
try {
// 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 날씨 정보 조회
WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation());
log.debug("날씨 정보 조회 완료: {} 도", weatherData.getTemperature());
// AI를 사용하여 마케팅 팁 생성
String tipContent = aiTipGenerator.generateTip(storeData, weatherData);
log.debug("AI 마케팅 팁 생성 완료");
// 마케팅 팁 도메인 객체 생성
MarketingTip marketingTip = MarketingTip.builder()
.storeId(request.getStoreId())
.tipContent(tipContent)
.weatherData(weatherData)
.storeData(storeData)
.createdAt(LocalDateTime.now())
.build();
// 마케팅 팁 저장
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
return MarketingTipResponse.builder()
.tipId(savedTip.getId().getValue())
.tipContent(savedTip.getTipContent())
.createdAt(savedTip.getCreatedAt())
.build();
} catch (Exception e) {
log.error("마케팅 팁 생성 중 오류 발생", e);
throw new BusinessException(ErrorCode.RECOMMENDATION_FAILED);
}
}
}
@@ -0,0 +1,19 @@
package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
/**
* 마케팅 팁 관련 Use Case 인터페이스
* AI 기반 마케팅 팁 생성 기능 정의
*/
public interface MarketingTipUseCase {
/**
* AI 마케팅 팁 생성
*
* @param request 마케팅 팁 생성 요청
* @return 생성된 마케팅 팁 응답
*/
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
}
@@ -0,0 +1,58 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
import java.time.LocalDateTime;
/**
* 마케팅 팁 도메인 모델
* AI가 생성한 마케팅 팁과 관련 정보를 관리
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MarketingTip {
/**
* 마케팅 팁 고유 식별자
*/
private TipId id;
/**
* 매장 ID
*/
private Long storeId;
/**
* AI가 생성한 마케팅 팁 내용
*/
private String tipContent;
/**
* 팁 생성 시 참고한 날씨 데이터
*/
private WeatherData weatherData;
/**
* 팁 생성 시 참고한 매장 데이터
*/
private StoreData storeData;
/**
* 팁 생성 시각
*/
private LocalDateTime createdAt;
/**
* 팁 내용 업데이트
*
* @param newContent 새로운 팁 내용
*/
public void updateContent(String newContent) {
if (newContent == null || newContent.trim().isEmpty()) {
throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다.");
}
this.tipContent = newContent.trim();
}
}
@@ -0,0 +1,66 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
/**
* 매장 데이터 값 객체
* 마케팅 팁 생성에 사용되는 매장 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode
public class StoreData {
/**
* 매장명
*/
private String storeName;
/**
* 업종
*/
private String businessType;
/**
* 매장 위치 (주소)
*/
private String location;
/**
* 매장 데이터 유효성 검증
*
* @return 유효성 여부
*/
public boolean isValid() {
return storeName != null && !storeName.trim().isEmpty() &&
businessType != null && !businessType.trim().isEmpty() &&
location != null && !location.trim().isEmpty();
}
/**
* 업종 카테고리 분류
*
* @return 업종 카테고리
*/
public String getBusinessCategory() {
if (businessType == null) {
return "기타";
}
String lowerCaseType = businessType.toLowerCase();
if (lowerCaseType.contains("카페") || lowerCaseType.contains("커피")) {
return "카페";
} else if (lowerCaseType.contains("식당") || lowerCaseType.contains("레스토랑")) {
return "음식점";
} else if (lowerCaseType.contains("베이커리") || lowerCaseType.contains("")) {
return "베이커리";
} else if (lowerCaseType.contains("치킨") || lowerCaseType.contains("피자")) {
return "패스트푸드";
} else {
return "기타";
}
}
}
@@ -0,0 +1,29 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
/**
* 마케팅 팁 식별자 값 객체
* 마케팅 팁의 고유 식별자를 나타내는 도메인 객체
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
public class TipId {
private Long value;
/**
* TipId 생성 팩토리 메서드
*
* @param value 식별자 값
* @return TipId 인스턴스
*/
public static TipId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("TipId는 양수여야 합니다.");
}
return new TipId(value);
}
}
@@ -0,0 +1,66 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
/**
* 날씨 데이터 값 객체
* 마케팅 팁 생성에 사용되는 날씨 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode
public class WeatherData {
/**
* 온도 (섭씨)
*/
private Double temperature;
/**
* 날씨 상태 (맑음, 흐림, 비, 눈 등)
*/
private String condition;
/**
* 습도 (%)
*/
private Double humidity;
/**
* 날씨 데이터 유효성 검증
*
* @return 유효성 여부
*/
public boolean isValid() {
return temperature != null &&
condition != null && !condition.trim().isEmpty() &&
humidity != null && humidity >= 0 && humidity <= 100;
}
/**
* 온도 기반 날씨 상태 설명
*
* @return 날씨 상태 설명
*/
public String getTemperatureDescription() {
if (temperature == null) {
return "알 수 없음";
}
if (temperature >= 30) {
return "매우 더움";
} else if (temperature >= 25) {
return "더움";
} else if (temperature >= 20) {
return "따뜻함";
} else if (temperature >= 10) {
return "선선함";
} else if (temperature >= 0) {
return "춥다";
} else {
return "매우 춥다";
}
}
}
@@ -0,0 +1,56 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.TipId;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 마케팅 팁 저장소 인터페이스
* 마케팅 팁 도메인의 데이터 접근 추상화
*/
public interface MarketingTipRepository {
/**
* 마케팅 팁 저장
*
* @param marketingTip 저장할 마케팅 팁
* @return 저장된 마케팅 팁
*/
MarketingTip save(MarketingTip marketingTip);
/**
* 마케팅 팁 ID로 조회
*
* @param id 마케팅 팁 ID
* @return 마케팅 팁 (Optional)
*/
Optional<MarketingTip> findById(TipId id);
/**
* 매장별 마케팅 팁 목록 조회
*
* @param storeId 매장 ID
* @return 마케팅 팁 목록
*/
List<MarketingTip> findByStoreId(Long storeId);
/**
* 특정 기간 내 생성된 마케팅 팁 조회
*
* @param storeId 매장 ID
* @param startDate 시작 시각
* @param endDate 종료 시각
* @return 마케팅 팁 목록
*/
List<MarketingTip> findByStoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDate, LocalDateTime endDate);
/**
* 마케팅 팁 삭제
*
* @param id 삭제할 마케팅 팁 ID
*/
void deleteById(TipId id);
}
@@ -0,0 +1,20 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.WeatherData;
/**
* AI 팁 생성 도메인 서비스 인터페이스
* AI를 활용한 마케팅 팁 생성 기능 정의
*/
public interface AiTipGenerator {
/**
* 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성
*
* @param storeData 매장 데이터
* @param weatherData 날씨 데이터
* @return AI가 생성한 마케팅 팁
*/
String generateTip(StoreData storeData, WeatherData weatherData);
}
@@ -0,0 +1,18 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
/**
* 매장 데이터 제공 도메인 서비스 인터페이스
* 외부 매장 서비스로부터 매장 정보 조회 기능 정의
*/
public interface StoreDataProvider {
/**
* 매장 ID로 매장 데이터 조회
*
* @param storeId 매장 ID
* @return 매장 데이터
*/
StoreData getStoreData(Long storeId);
}
@@ -0,0 +1,18 @@
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);
}
@@ -0,0 +1,190 @@
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.getBusinessCategory();
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; }
}
}
}
@@ -0,0 +1,110 @@
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.service.StoreDataProvider;
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 org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
/**
* 매장 API 데이터 제공자 구현체
* 외부 매장 서비스 API를 통해 매장 정보 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StoreApiDataProvider implements StoreDataProvider {
private final WebClient webClient;
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
/**
* 매장 ID로 매장 데이터 조회
*
* @param storeId 매장 ID
* @return 매장 데이터
*/
@Override
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시작: storeId={}", storeId);
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store?storeId=" + storeId)
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofSeconds(10))
.block();
if (response == null || response.getData() == null) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
StoreApiData storeApiData = response.getData();
StoreData storeData = StoreData.builder()
.storeName(storeApiData.getStoreName())
.businessType(storeApiData.getBusinessType())
.location(storeApiData.getAddress())
.build();
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
return storeData;
} catch (WebClientResponseException e) {
log.error("매장 서비스 API 호출 실패: storeId={}, status={}", storeId, e.getStatusCode(), e);
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
} catch (Exception e) {
log.error("매장 정보 조회 중 오류 발생: storeId={}", storeId, e);
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
}
}
/**
* 매장 API 응답 DTO
*/
private static class StoreApiResponse {
private int status;
private String message;
private StoreApiData data;
// Getters and Setters
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public StoreApiData getData() { return data; }
public void setData(StoreApiData data) { this.data = data; }
}
/**
* 매장 API 데이터 DTO
*/
private static class StoreApiData {
private Long storeId;
private String storeName;
private String businessType;
private String address;
// Getters and Setters
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getStoreName() { return storeName; }
public void setStoreName(String storeName) { this.storeName = storeName; }
public String getBusinessType() { return businessType; }
public void setBusinessType(String businessType) { this.businessType = businessType; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
}
}
@@ -0,0 +1,155 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.WeatherData;
import com.won.smarketing.recommend.domain.service.WeatherDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
/**
* 날씨 API 데이터 제공자 구현체
* 외부 날씨 API를 통해 날씨 정보 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WeatherApiDataProvider implements WeatherDataProvider {
private final WebClient webClient;
@Value("${external.weather-api.api-key}")
private String weatherApiKey;
@Value("${external.weather-api.base-url}")
private String weatherApiBaseUrl;
/**
* 특정 위치의 현재 날씨 정보 조회
*
* @param location 위치 (주소)
* @return 날씨 데이터
*/
@Override
public WeatherApiResponse getCurrentWeather(String location) {
try {
log.debug("날씨 정보 조회 시작: location={}", location);
// 한국 주요 도시로 단순화
String city = extractCity(location);
WeatherApiResponse response = webClient
.get()
.uri(uriBuilder -> uriBuilder
.scheme("https")
.host("api.openweathermap.org")
.path("/data/2.5/weather")
.queryParam("q", city + ",KR")
.queryParam("appid", weatherApiKey)
.queryParam("units", "metric")
.queryParam("lang", "kr")
.build())
.retrieve()
.bodyToMono(WeatherApiResponse.class)
.timeout(Duration.ofSeconds(10))
.onErrorReturn(createDefaultWeatherData()) // 오류 시 기본값 반환
.block();
if (response == null) {
return createDefaultWeatherData();
}
WeatherData weatherData = WeatherData.builder()
.temperature(response.getMain().getTemp())
.condition(response.getWeather()[0].getDescription())
.humidity(response.getMain().getHumidity())
.build();
log.debug("날씨 정보 조회 완료: {}도, {}", weatherData.getTemperature(), weatherData.getCondition());
return weatherData;
} catch (Exception e) {
log.warn("날씨 정보 조회 실패, 기본값 사용: location={}", location, e);
return createDefaultWeatherData();
}
}
/**
* 주소에서 도시명 추출
*
* @param location 전체 주소
* @return 도시명
*/
private String extractCity(String location) {
if (location == null || location.trim().isEmpty()) {
return "Seoul";
}
// 서울, 부산, 대구, 인천, 광주, 대전, 울산 등 주요 도시 추출
String[] cities = {"서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "수원", "창원"};
for (String city : cities) {
if (location.contains(city)) {
return city;
}
}
return "Seoul"; // 기본값
}
/**
* 기본 날씨 데이터 생성 (API 호출 실패 시 사용)
*
* @return 기본 날씨 데이터
*/
private WeatherApiResponse createDefaultWeatherData() {
WeatherApiResponse response = new WeatherApiResponse();
response.setMain(new WeatherApiResponse.Main());
response.getMain().setTemp(20.0); // 기본 온도 20도
response.getMain().setHumidity(60.0); // 기본 습도 60%
WeatherApiResponse.Weather[] weather = new WeatherApiResponse.Weather[1];
weather[0] = new WeatherApiResponse.Weather();
weather[0].setDescription("맑음");
response.setWeather(weather);
return response;
}
/**
* 날씨 API 응답 DTO
*/
private static class WeatherApiResponse {
private Main main;
private Weather[] weather;
public Main getMain() { return main; }
public void setMain(Main main) { this.main = main; }
public Weather[] getWeather() { return weather; }
public void setWeather(Weather[] weather) { this.weather = weather; }
static class Main {
private Double temp;
private Double humidity;
public Double getTemp() { return temp; }
public void setTemp(Double temp) { this.temp = temp; }
public Double getHumidity() { return humidity; }
public void setHumidity(Double humidity) { this.humidity = humidity; }
}
static class Weather {
private String description;
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
}
}
@@ -0,0 +1,39 @@
package com.won.smarketing.recommend.presentation.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* AI 마케팅 추천을 위한 REST API 컨트롤러
* AI 기반 마케팅 팁 생성 기능 제공
*/
@Tag(name = "AI 마케팅 추천", description = "AI 기반 맞춤형 마케팅 추천 API")
@RestController
@RequestMapping("/api/recommendation")
@RequiredArgsConstructor
public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
/**
* AI 마케팅 팁 생성
*
* @param request 마케팅 팁 생성 요청
* @return 생성된 마케팅 팁
*/
@Operation(summary = "AI 마케팅 팁 생성", description = "매장 특성과 환경 정보를 바탕으로 AI 마케팅 팁을 생성합니다.")
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(@Valid @RequestBody MarketingTipRequest request) {
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
return ResponseEntity.ok(ApiResponse.success(response, "AI 마케팅 팁이 성공적으로 생성되었습니다."));
}
}
@@ -0,0 +1,34 @@
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;
}
@@ -0,0 +1,32 @@
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;
}
@@ -0,0 +1,29 @@
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;
}
@@ -0,0 +1,24 @@
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
* 매장 정보를 기반으로 개인화된 마케팅 팁을 요청할 때 사용됩니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "AI 마케팅 팁 생성 요청")
public class MarketingTipRequest {
@NotNull(message = "매장 ID는 필수입니다")
@Positive(message = "매장 ID는 양수여야 합니다")
@Schema(description = "매장 ID", example = "1", required = true)
private Long storeId;
}
@@ -0,0 +1,31 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* AI 마케팅 팁 생성 응답 DTO
* AI가 생성한 개인화된 마케팅 팁 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "AI 마케팅 팁 생성 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)",
example = "오늘 같은 비 오는 날에는 따뜻한 음료와 함께 실내 분위기를 강조한 포스팅을 올려보세요. #비오는날카페 #따뜻한음료 해시태그로 감성을 어필해보세요!")
private String tipContent;
@Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
}
@@ -0,0 +1,26 @@
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;
}
@@ -0,0 +1,26 @@
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;
}
@@ -0,0 +1,50 @@
server:
port: ${SERVER_PORT:8084}
servlet:
context-path: /
spring:
application:
name: ai-recommend-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommenddb}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8080/ai}
timeout: ${AI_SERVICE_TIMEOUT:30000}
external:
claude-ai:
api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key}
base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com}
model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229}
max-tokens: ${CLAUDE_AI_MAX_TOKENS:2000}
weather-api:
api-key: ${WEATHER_API_KEY:your-weather-api-key}
base-url: ${WEATHER_API_BASE_URL:https://api.openweathermap.org/data/2.5}
store-service:
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
logging:
level:
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}