diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..fc7acb6 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "customColor": "", + "associatedIndex": 4 +} + + + + + + + + + + + + + + + true + true + false + false + + + + + + + 1749618504890 + + + + \ No newline at end of file diff --git a/Common module should not have bootJar b/Common module should not have bootJar deleted file mode 100644 index 0b49c8c..0000000 --- a/Common module should not have bootJar +++ /dev/null @@ -1,8 +0,0 @@ -tasks.getByName('bootJar') { - enabled = false -} - -tasks.getByName('jar') { - enabled = true - archiveClassifier = '' -} diff --git a/ai-recommend/build.gradle b/ai-recommend/build.gradle deleted file mode 100644 index 6306f15..0000000 --- a/ai-recommend/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -dependencies { - implementation project(':common') - - // HTTP Client for external API - implementation 'org.springframework.boot:spring-boot-starter-webflux' -} - -bootJar { - archiveFileName = "ai-recommend-service.jar" -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java deleted file mode 100644 index 6ebb3f5..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java +++ /dev/null @@ -1,20 +0,0 @@ -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); - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java deleted file mode 100644 index 7d80205..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ /dev/null @@ -1,83 +0,0 @@ -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); - } - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java deleted file mode 100644 index b5e6598..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java +++ /dev/null @@ -1,19 +0,0 @@ -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); -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java deleted file mode 100644 index 0f38f43..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java +++ /dev/null @@ -1,66 +0,0 @@ -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 "기타"; - } - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java deleted file mode 100644 index ae0b1df..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java +++ /dev/null @@ -1,29 +0,0 @@ -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); - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java deleted file mode 100644 index c1d4f54..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/WeatherData.java +++ /dev/null @@ -1,66 +0,0 @@ -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 "매우 춥다"; - } - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java deleted file mode 100644 index fd5e537..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java +++ /dev/null @@ -1,56 +0,0 @@ -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 findById(TipId id); - - /** - * 매장별 마케팅 팁 목록 조회 - * - * @param storeId 매장 ID - * @return 마케팅 팁 목록 - */ - List findByStoreId(Long storeId); - - /** - * 특정 기간 내 생성된 마케팅 팁 조회 - * - * @param storeId 매장 ID - * @param startDate 시작 시각 - * @param endDate 종료 시각 - * @return 마케팅 팁 목록 - */ - List findByStoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDate, LocalDateTime endDate); - - /** - * 마케팅 팁 삭제 - * - * @param id 삭제할 마케팅 팁 ID - */ - void deleteById(TipId id); -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java deleted file mode 100644 index 8680caa..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java +++ /dev/null @@ -1,20 +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; - -/** - * AI 팁 생성 도메인 서비스 인터페이스 - * AI를 활용한 마케팅 팁 생성 기능 정의 - */ -public interface AiTipGenerator { - - /** - * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return AI가 생성한 마케팅 팁 - */ - String generateTip(StoreData storeData, WeatherData weatherData); -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java deleted file mode 100644 index 5129f46..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/WeatherDataProvider.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.won.smarketing.recommend.domain.service; - -import com.won.smarketing.recommend.domain.model.WeatherData; - -/** - * 날씨 데이터 제공 도메인 서비스 인터페이스 - * 외부 날씨 API로부터 날씨 정보 조회 기능 정의 - */ -public interface WeatherDataProvider { - - /** - * 특정 위치의 현재 날씨 정보 조회 - * - * @param location 위치 (주소) - * @return 날씨 데이터 - */ - WeatherData getCurrentWeather(String location); -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java deleted file mode 100644 index bb93ce3..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/ClaudeAiTipGenerator.java +++ /dev/null @@ -1,248 +0,0 @@ -package com.won.smarketing.recommend.infrastructure.external; - -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; -import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.WeatherData; -import com.won.smarketing.recommend.domain.service.AiTipGenerator; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; -import reactor.core.publisher.Mono; - -import java.time.Duration; -import java.util.Map; - -/** - * Claude AI 팁 생성기 구현체 - * Claude AI API를 통해 마케팅 팁 생성 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class ClaudeAiTipGenerator implements AiTipGenerator { - - private final WebClient webClient; - - @Value("${external.claude-ai.api-key}") - private String claudeApiKey; - - @Value("${external.claude-ai.base-url}") - private String claudeApiBaseUrl; - - @Value("${external.claude-ai.model}") - private String claudeModel; - - @Value("${external.claude-ai.max-tokens}") - private Integer maxTokens; - - /** - * 매장 정보와 날씨 정보를 바탕으로 마케팅 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return AI가 생성한 마케팅 팁 - */ - @Override - public String generateTip(StoreData storeData, WeatherData weatherData) { - try { - log.debug("AI 마케팅 팁 생성 시작: store={}, weather={}도", - storeData.getStoreName(), weatherData.getTemperature()); - - String prompt = buildPrompt(storeData, weatherData); - - Map requestBody = Map.of( - "model", claudeModel, - "max_tokens", maxTokens, - "messages", new Object[]{ - Map.of( - "role", "user", - "content", prompt - ) - } - ); - - ClaudeApiResponse response = webClient - .post() - .uri(claudeApiBaseUrl + "/v1/messages") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + claudeApiKey) - .header("anthropic-version", "2023-06-01") - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(requestBody) - .retrieve() - .bodyToMono(ClaudeApiResponse.class) - .timeout(Duration.ofSeconds(30)) - .block(); - - if (response == null || response.getContent() == null || response.getContent().length == 0) { - throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE); - } - - String generatedTip = response.getContent()[0].getText(); - - // 100자 제한 적용 - if (generatedTip.length() > 100) { - generatedTip = generatedTip.substring(0, 97) + "..."; - } - - log.debug("AI 마케팅 팁 생성 완료: length={}", generatedTip.length()); - return generatedTip; - - } catch (WebClientResponseException e) { - log.error("Claude AI API 호출 실패: status={}", e.getStatusCode(), e); - return generateFallbackTip(storeData, weatherData); - } catch (Exception e) { - log.error("AI 마케팅 팁 생성 중 오류 발생", e); - return generateFallbackTip(storeData, weatherData); - } - } - - /** - * AI 프롬프트 구성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return 프롬프트 문자열 - */ - private String buildPrompt(StoreData storeData, WeatherData weatherData) { - return String.format( - "다음 매장을 위한 오늘의 마케팅 팁을 100자 이내로 작성해주세요.\n\n" + - "매장 정보:\n" + - "- 매장명: %s\n" + - "- 업종: %s\n" + - "- 위치: %s\n\n" + - "오늘 날씨:\n" + - "- 온도: %.1f도\n" + - "- 날씨: %s\n" + - "- 습도: %.1f%%\n\n" + - "날씨와 매장 특성을 고려한 실용적이고 구체적인 마케팅 팁을 제안해주세요. " + - "반드시 100자 이내로 작성하고, 친근하고 실행 가능한 조언을 해주세요.", - storeData.getStoreName(), - storeData.getBusinessType(), - storeData.getLocation(), - weatherData.getTemperature(), - weatherData.getCondition(), - weatherData.getHumidity() - ); - } - - /** - * AI API 실패 시 대체 팁 생성 - * - * @param storeData 매장 데이터 - * @param weatherData 날씨 데이터 - * @return 대체 마케팅 팁 - */ - private String generateFallbackTip(StoreData storeData, WeatherData weatherData) { - StringBuilder tip = new StringBuilder(); - - // 날씨 기반 기본 팁 - if (weatherData.getTemperature() >= 25) { - tip.append("더운 날씨에는 시원한 음료나 디저트를 홍보해보세요! "); - } else if (weatherData.getTemperature() <= 10) { - tip.append("추운 날씨에는 따뜻한 메뉴를 강조해보세요! "); - } else { - tip.append("좋은 날씨를 활용한 야외석 이용을 추천해보세요! "); - } - - // 업종별 기본 팁 - String businessCategory = storeData.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; } - } - } -}/model/MarketingTip.java -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(); - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java deleted file mode 100644 index 51efb70..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java +++ /dev/null @@ -1,110 +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.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; } - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java deleted file mode 100644 index 3fcf9dd..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/WeatherApiDataProvider.java +++ /dev/null @@ -1,155 +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.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 WeatherData getCurrentWeather(String location) { - try { - log.debug("날씨 정보 조회 시작: location={}", location); - - // 한국 주요 도시로 단순화 - String city = extractCity(location); - - WeatherApiResponse response = webClient - .get() - .uri(uriBuilder -> uriBuilder - .scheme("https") - .host("api.openweathermap.org") - .path("/data/2.5/weather") - .queryParam("q", city + ",KR") - .queryParam("appid", weatherApiKey) - .queryParam("units", "metric") - .queryParam("lang", "kr") - .build()) - .retrieve() - .bodyToMono(WeatherApiResponse.class) - .timeout(Duration.ofSeconds(10)) - .onErrorReturn(createDefaultWeatherData()) // 오류 시 기본값 반환 - .block(); - - 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; } - } - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java deleted file mode 100644 index 182ee6e..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java +++ /dev/null @@ -1,39 +0,0 @@ -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 jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - - -/** - * 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> generateMarketingTips(@Valid @RequestBody MarketingTipRequest request) { - MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); - return ResponseEntity.ok(ApiResponse.success(response, "AI 마케팅 팁이 성공적으로 생성되었습니다.")); - } -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java deleted file mode 100644 index 9e90fc8..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/DetailedMarketingTipResponse.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * 상세 AI 마케팅 팁 응답 DTO - * AI 마케팅 팁과 함께 생성 시 사용된 환경 데이터도 포함합니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "상세 AI 마케팅 팁 응답") -public class DetailedMarketingTipResponse { - - @Schema(description = "팁 ID", example = "1") - private Long tipId; - - @Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)") - private String tipContent; - - @Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00") - private LocalDateTime createdAt; - - @Schema(description = "팁 생성 시 참고된 날씨 정보") - private WeatherInfoDto weatherInfo; - - @Schema(description = "팁 생성 시 참고된 매장 정보") - private StoreInfoDto storeInfo; -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java deleted file mode 100644 index 335d5ab..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/ErrorResponseDto.java +++ /dev/null @@ -1,31 +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; -} \ No newline at end of file diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java deleted file mode 100644 index 70de05e..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipGenerationRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * AI 마케팅 팁 생성을 위한 내부 요청 DTO - * 애플리케이션 계층에서 AI 서비스 호출 시 사용됩니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "AI 마케팅 팁 생성 내부 요청") -public class MarketingTipGenerationRequest { - - @NotNull(message = "매장 정보는 필수입니다") - @Schema(description = "매장 정보", required = true) - private StoreInfoDto storeInfo; - - @Schema(description = "현재 날씨 정보") - private WeatherInfoDto weatherInfo; - - @Schema(description = "팁 생성 옵션", example = "일반") - private String tipType; -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java deleted file mode 100644 index 26e2331..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java +++ /dev/null @@ -1,29 +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 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; -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java deleted file mode 100644 index aae7983..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/StoreInfoDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 매장 정보 DTO - * AI 마케팅 팁 생성 시 매장 특성을 반영하기 위한 정보입니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "매장 정보") -public class StoreInfoDto { - - @Schema(description = "매장명", example = "카페 원더풀") - private String storeName; - - @Schema(description = "업종", example = "카페") - private String businessType; - - @Schema(description = "매장 위치", example = "서울시 강남구") - private String location; -} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java b/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java deleted file mode 100644 index 9757f11..0000000 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/WeatherInfoDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.won.smarketing.recommend.presentation.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 날씨 정보 DTO - * AI 마케팅 팁 생성 시 참고되는 환경 데이터입니다. - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Schema(description = "날씨 정보") -public class WeatherInfoDto { - - @Schema(description = "기온 (섭씨)", example = "23.5") - private Double temperature; - - @Schema(description = "날씨 상태", example = "맑음") - private String condition; - - @Schema(description = "습도 (%)", example = "65.0") - private Double humidity; -} diff --git a/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java deleted file mode 100644 index 00fcce0..0000000 --- a/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.won.smarketing.common.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -/** - * Swagger 설정 클래스 - * API 문서화를 위한 OpenAPI 설정 - */ -@Configuration -public class SwaggerConfig { - - /** - * OpenAPI 설정 - * - * @return OpenAPI 인스턴스 - */ - @Bean - public OpenAPI openAPI() { - String securitySchemeName = "bearerAuth"; - - return new OpenAPI() - .info(new Info() - .title("AI 마케팅 서비스 API") - .description("소상공인을 위한 맞춤형 AI 마케팅 솔루션 API 문서") - .version("v1.0.0")) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components(new Components() - .addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - } -} diff --git a/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java deleted file mode 100644 index af2b74a..0000000 --- a/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.won.smarketing.common.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -/** - * 페이지네이션 응답 DTO - * 페이지 단위 조회 결과를 담는 공통 형식 - * - * @param 페이지 내용의 데이터 타입 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "페이지네이션 응답") -public class PageResponse { - - @Schema(description = "페이지 내용") - private List content; - - @Schema(description = "현재 페이지 번호", example = "0") - private int pageNumber; - - @Schema(description = "페이지 크기", example = "20") - private int pageSize; - - @Schema(description = "전체 요소 수", example = "100") - private long totalElements; - - @Schema(description = "전체 페이지 수", example = "5") - private int totalPages; - - @Schema(description = "첫 번째 페이지 여부", example = "true") - private boolean first; - - @Schema(description = "마지막 페이지 여부", example = "false") - private boolean last; -} diff --git a/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java deleted file mode 100644 index 05ff103..0000000 --- a/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.won.smarketing.common.exception; - -import com.won.smarketing.common.dto.ApiResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.BindException; -import org.springframework.web.HttpRequestMethodNotSupportedException; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.MissingServletRequestParameterException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.nio.file.AccessDeniedException; - -/** - * 전역 예외 처리 핸들러 - * 애플리케이션에서 발생하는 모든 예외를 처리하여 일관된 응답 형식 제공 - */ -@Slf4j -@RestControllerAdvice -public class GlobalExceptionHandler { - - /** - * 비즈니스 예외 처리 - * - * @param ex 비즈니스 예외 - * @return 오류 응답 - */ - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusinessException(BusinessException ex) { - log.warn("Business exception occurred: {}", ex.getMessage()); - ErrorCode errorCode = ex.getErrorCode(); - return ResponseEntity - .status(errorCode.getHttpStatus()) - .body(ApiResponse.error(errorCode.getHttpStatus().value(), ex.getMessage())); - } - - /** - * 유효성 검증 예외 처리 (@Valid 애노테이션) - * - * @param ex 유효성 검증 예외 - * @return 오류 응답 - */ - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException ex) { - log.warn("Validation exception occurred: {}", ex.getMessage()); - String errorMessage = ex.getBindingResult() - .getFieldErrors() - .stream() - .findFirst() - .map(error -> error.getDefaultMessage()) - .orElse("유효성 검증에 실패했습니다."); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); - } - - /** - * 바인딩 예외 처리 - * - * @param ex 바인딩 예외 - * @return 오류 응답 - */ - @ExceptionHandler(BindException.class) - public ResponseEntity> handleBindException(BindException ex) { - log.warn("Bind exception occurred: {}", ex.getMessage()); - String errorMessage = ex.getBindingResult() - .getFieldErrors() - .stream() - .findFirst() - .map(error -> error.getDefaultMessage()) - .orElse("요청 데이터 바인딩에 실패했습니다."); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage)); - } - - /** - * 타입 불일치 예외 처리 - * - * @param ex 타입 불일치 예외 - * @return 오류 응답 - */ - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) { - log.warn("Type mismatch exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 타입의 값입니다.")); - } - - /** - * 필수 파라미터 누락 예외 처리 - * - * @param ex 필수 파라미터 누락 예외 - * @return 오류 응답 - */ - @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity> handleMissingParameterException(MissingServletRequestParameterException ex) { - log.warn("Missing parameter exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "필수 요청 파라미터가 누락되었습니다: " + ex.getParameterName())); - } - - /** - * HTTP 메서드 불일치 예외 처리 - * - * @param ex HTTP 메서드 불일치 예외 - * @return 오류 응답 - */ - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { - log.warn("Method not supported exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.METHOD_NOT_ALLOWED) - .body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메서드입니다.")); - } - - /** - * 접근 거부 예외 처리 - * - * @param ex 접근 거부 예외 - * @return 오류 응답 - */ - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { - log.warn("Access denied exception occurred: {}", ex.getMessage()); - return ResponseEntity - .status(HttpStatus.FORBIDDEN) - .body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다.")); - } - - /** - * 기타 모든 예외 처리 - * - * @param ex 예외 - * @return 오류 응답 - */ - @ExceptionHandler(Exception.class) - public ResponseEntity> handleGenericException(Exception ex) { - log.error("Unexpected exception occurred", ex); - return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다.")); - } -} diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java deleted file mode 100644 index 86de936..0000000 --- a/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.won.smarketing.common.security; - -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; -import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.util.Date; - -/** - * JWT 토큰 생성 및 검증 유틸리티 - * Access Token과 Refresh Token 관리 - */ -@Slf4j -@Component -public class JwtTokenProvider { - - private final SecretKey key; - private final long accessTokenValidityTime; - private final long refreshTokenValidityTime; - - /** - * JWT 토큰 프로바이더 생성자 - * - * @param secretKey JWT 서명에 사용할 비밀키 - * @param accessTokenValidityTime Access Token 유효 시간 (밀리초) - * @param refreshTokenValidityTime Refresh Token 유효 시간 (밀리초) - */ - public JwtTokenProvider( - @Value("${jwt.secret-key}") String secretKey, - @Value("${jwt.access-token-validity}") long accessTokenValidityTime, - @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - this.key = Keys.hmacShaKeyFor(keyBytes); - this.accessTokenValidityTime = accessTokenValidityTime; - this.refreshTokenValidityTime = refreshTokenValidityTime; - } - - /** - * Access Token 생성 - * - * @param userId 사용자 ID - * @return Access Token - */ - public String generateAccessToken(String userId) { - long now = System.currentTimeMillis(); - Date validity = new Date(now + accessTokenValidityTime); - - return Jwts.builder() - .setSubject(userId) - .setIssuedAt(new Date(now)) - .setExpiration(validity) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - /** - * Refresh Token 생성 - * - * @param userId 사용자 ID - * @return Refresh Token - */ - public String generateRefreshToken(String userId) { - long now = System.currentTimeMillis(); - Date validity = new Date(now + refreshTokenValidityTime); - - return Jwts.builder() - .setSubject(userId) - .setIssuedAt(new Date(now)) - .setExpiration(validity) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - /** - * 토큰에서 사용자 ID 추출 - * - * @param token JWT 토큰 - * @return 사용자 ID - */ - public String getUserIdFromToken(String token) { - Claims claims = parseClaims(token); - return claims.getSubject(); - } - - /** - * 토큰 유효성 검증 - * - * @param token JWT 토큰 - * @return 유효성 여부 - */ - public boolean validateToken(String token) { - try { - parseClaims(token); - return true; - } catch (ExpiredJwtException e) { - log.warn("Expired JWT token: {}", e.getMessage()); - throw new BusinessException(ErrorCode.TOKEN_EXPIRED); - } catch (UnsupportedJwtException e) { - log.warn("Unsupported JWT token: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } catch (MalformedJwtException e) { - log.warn("Malformed JWT token: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } catch (SecurityException e) { - log.warn("Invalid JWT signature: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } catch (IllegalArgumentException e) { - log.warn("JWT token compact of handler are invalid: {}", e.getMessage()); - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } - } - - /** - * 토큰에서 Claims 추출 - * - * @param token JWT 토큰 - * @return Claims - */ - private Claims parseClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - } - - /** - * Access Token 유효 시간 반환 - * - * @return Access Token 유효 시간 (밀리초) - */ - public long getAccessTokenValidityTime() { - return accessTokenValidityTime; - } - - /** - * Refresh Token 유효 시간 반환 - * - * @return Refresh Token 유효 시간 (밀리초) - */ - public long getRefreshTokenValidityTime() { - return refreshTokenValidityTime; - } -} diff --git a/marketing-content/build.gradle b/marketing-content/build.gradle deleted file mode 100644 index bb3b8ea..0000000 --- a/marketing-content/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -dependencies { - implementation project(':common') - - // HTTP Client for external AI API - implementation 'org.springframework.boot:spring-boot-starter-webflux' -} - -bootJar { - archiveFileName = "marketing-content-service.jar" -} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java deleted file mode 100644 index 6eee928..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.*; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; - -/** - * 마케팅 콘텐츠 도메인 모델 - * 콘텐츠의 핵심 비즈니스 로직과 상태를 관리 - */ -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Content { - - /** - * 콘텐츠 고유 식별자 - */ - private ContentId id; - - /** - * 콘텐츠 타입 (SNS 게시물, 포스터 등) - */ - private ContentType contentType; - - /** - * 플랫폼 (인스타그램, 네이버 블로그 등) - */ - private Platform platform; - - /** - * 콘텐츠 제목 - */ - private String title; - - /** - * 콘텐츠 내용 - */ - private String content; - - /** - * 해시태그 목록 - */ - private List hashtags; - - /** - * 이미지 URL 목록 - */ - private List images; - - /** - * 콘텐츠 상태 - */ - private ContentStatus status; - - /** - * 콘텐츠 생성 조건 - */ - private CreationConditions creationConditions; - - /** - * 매장 ID - */ - private Long storeId; - - /** - * 생성 시각 - */ - private LocalDateTime createdAt; - - /** - * 수정 시각 - */ - private LocalDateTime updatedAt; - - /** - * 콘텐츠 제목 업데이트 - * - * @param title 새로운 제목 - */ - public void updateTitle(String title) { - this.title = title; - this.updatedAt = LocalDateTime.now(); - } - - /** - * 콘텐츠 기간 업데이트 - * - * @param startDate 시작일 - * @param endDate 종료일 - */ - public void updatePeriod(LocalDate startDate, LocalDate endDate) { - if (this.creationConditions != null) { - this.creationConditions = this.creationConditions.toBuilder() - .startDate(startDate) - .endDate(endDate) - .build(); - } - this.updatedAt = LocalDateTime.now(); - } - - /** - * 콘텐츠 상태 변경 - * - * @param status 새로운 상태 - */ - public void changeStatus(ContentStatus status) { - this.status = status; - this.updatedAt = LocalDateTime.now(); - } -} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java deleted file mode 100644 index b2a77bb..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** - * 콘텐츠 식별자 값 객체 - * 콘텐츠의 고유 식별자를 나타내는 도메인 객체 - */ -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@EqualsAndHashCode -public class ContentId { - - private Long value; - - /** - * ContentId 생성 팩토리 메서드 - * - * @param value 식별자 값 - * @return ContentId 인스턴스 - */ - public static ContentId of(Long value) { - if (value == null || value <= 0) { - throw new IllegalArgumentException("ContentId는 양수여야 합니다."); - } - return new ContentId(value); - } -} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java deleted file mode 100644 index c40ec47..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -/** - * 콘텐츠 상태 열거형 - * 콘텐츠의 생명주기 상태 정의 - */ -@Getter -@RequiredArgsConstructor -public enum ContentStatus { - - DRAFT("임시저장"), - PUBLISHED("발행됨"), - ARCHIVED("보관됨"); - - private final String displayName; - - /** - * 문자열로부터 ContentStatus 변환 - * - * @param status 상태 문자열 - * @return ContentStatus - */ - public static ContentStatus fromString(String status) { - if (status == null) { - return DRAFT; - } - - for (ContentStatus s : ContentStatus.values()) { - if (s.name().equalsIgnoreCase(status)) { - return s; - } - } - - throw new IllegalArgumentException("알 수 없는 콘텐츠 상태: " + status); - } -} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java deleted file mode 100644 index dd91b91..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -/** - * 콘텐츠 타입 열거형 - * 지원되는 마케팅 콘텐츠 유형 정의 - */ -@Getter -@RequiredArgsConstructor -public enum ContentType { - - SNS_POST("SNS 게시물"), - POSTER("홍보 포스터"); - - private final String displayName; - - /** - * 문자열로부터 ContentType 변환 - * - * @param type 타입 문자열 - * @return ContentType - */ - public static ContentType fromString(String type) { - if (type == null) { - return null; - } - - for (ContentType contentType : ContentType.values()) { - if (contentType.name().equalsIgnoreCase(type)) { - return contentType; - } - } - - throw new IllegalArgumentException("알 수 없는 콘텐츠 타입: " + type); - } -} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java deleted file mode 100644 index b76a152..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.*; - -import java.time.LocalDate; - -/** - * 콘텐츠 생성 조건 도메인 모델 - * AI 콘텐츠 생성 시 사용되는 조건 정보 - */ -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder(toBuilder = true) -public class CreationConditions { - - /** - * 홍보 대상 카테고리 - */ - private String category; - - /** - * 특별 요구사항 - */ - private String requirement; - - /** - * 톤앤매너 - */ - private String toneAndManner; - - /** - * 감정 강도 - */ - private String emotionIntensity; - - /** - * 이벤트명 - */ - private String eventName; - - /** - * 홍보 시작일 - */ - private LocalDate startDate; - - /** - * 홍보 종료일 - */ - private LocalDate endDate; - - /** - * 사진 스타일 (포스터용) - */ - private String photoStyle; -} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java deleted file mode 100644 index acd6b33..0000000 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.won.smarketing.content.domain.model; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -/** - * 플랫폼 열거형 - * 콘텐츠가 게시될 플랫폼 정의 - */ -@Getter -@RequiredArgsConstructor -public enum Platform { - - INSTAGRAM("인스타그램"), - NAVER_BLOG("네이버 블로그"), - GENERAL("범용"); - - private final String displayName; - - /** - * 문자열로부터 Platform 변환 - * - * @param platform 플랫폼 문자열 - * @return Platform - */ - public static Platform fromString(String platform) { - if (platform == null) { - return GENERAL; - } - - for (Platform p : Platform.values()) { - if (p.name().equalsIgnoreCase(platform)) { - return p; - } - } - - throw new IllegalArgumentException("알 수 없는 플랫폼: " + platform); - } -} diff --git a/marketing-content/src/main/resources/application.yml b/marketing-content/src/main/resources/application.yml deleted file mode 100644 index 6d8cbfc..0000000 --- a/marketing-content/src/main/resources/application.yml +++ /dev/null @@ -1,38 +0,0 @@ -server: - port: ${SERVER_PORT:8083} - servlet: - context-path: / - -spring: - application: - name: marketing-content-service - datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:contentdb} - 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 - -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} - -springdoc: - swagger-ui: - path: /swagger-ui.html - operations-sorter: method - api-docs: - path: /api-docs - -logging: - level: - com.won.smarketing.content: ${LOG_LEVEL:DEBUG} diff --git a/member/build.gradle b/member/build.gradle deleted file mode 100644 index d375e00..0000000 --- a/member/build.gradle +++ /dev/null @@ -1,7 +0,0 @@ -dependencies { - implementation project(':common') -} - -bootJar { - archiveFileName = "member-service.jar" -} diff --git a/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/member/src/main/java/com/won/smarketing/member/controller/MemberController.java deleted file mode 100644 index e47078c..0000000 --- a/member/src/main/java/com/won/smarketing/member/controller/MemberController.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.won.smarketing.member.controller; - -import com.won.smarketing.common.dto.ApiResponse; -import com.won.smarketing.member.dto.DuplicateCheckResponse; -import com.won.smarketing.member.dto.PasswordValidationRequest; -import com.won.smarketing.member.dto.RegisterRequest; -import com.won.smarketing.member.dto.ValidationResponse; -import com.won.smarketing.member.service.MemberService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - - -/** - * 회원 관리를 위한 REST API 컨트롤러 - * 회원가입, ID 중복 확인, 패스워드 유효성 검증 기능 제공 - */ -@Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API") -@RestController -@RequestMapping("/api/member") -@RequiredArgsConstructor -public class MemberController { - - private final MemberService memberService; - - /** - * 회원가입 처리 - * - * @param request 회원가입 요청 정보 - * @return 회원가입 성공/실패 응답 - */ - @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") - @PostMapping("/register") - public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { - memberService.register(request); - return ResponseEntity.ok(ApiResponse.success(null, "회원가입이 완료되었습니다.")); - } - - /** - * ID 중복 확인 - * - * @param userId 확인할 사용자 ID - * @return 중복 여부 응답 - */ - @Operation(summary = "ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") - @GetMapping("/check-duplicate") - public ResponseEntity> checkDuplicate( - @Parameter(description = "확인할 사용자 ID", required = true) - @RequestParam String userId) { - boolean isDuplicate = memberService.checkDuplicate(userId); - DuplicateCheckResponse response = DuplicateCheckResponse.builder() - .isDuplicate(isDuplicate) - .message(isDuplicate ? "이미 사용 중인 ID입니다." : "사용 가능한 ID입니다.") - .build(); - return ResponseEntity.ok(ApiResponse.success(response)); - } - - /** - * 패스워드 유효성 검증 - * - * @param request 패스워드 유효성 검증 요청 - * @return 유효성 검증 결과 - */ - @Operation(summary = "패스워드 유효성 검증", description = "패스워드가 보안 규칙을 만족하는지 확인합니다.") - @PostMapping("/validate-password") - public ResponseEntity> validatePassword(@Valid @RequestBody PasswordValidationRequest request) { - ValidationResponse response = memberService.validatePassword(request.getPassword()); - return ResponseEntity.ok(ApiResponse.success(response)); - } -} diff --git a/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java deleted file mode 100644 index 99d1763..0000000 --- a/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.won.smarketing.member.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * ID 중복 확인 응답 DTO - * 사용자 ID 중복 여부 확인 결과 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "ID 중복 확인 응답") -public class DuplicateCheckResponse { - - @Schema(description = "중복 여부", example = "false") - private boolean isDuplicate; - - @Schema(description = "확인 결과 메시지", example = "사용 가능한 ID입니다.") - private String message; -} diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java deleted file mode 100644 index 5769e70..0000000 --- a/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.won.smarketing.member.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 로그인 응답 DTO - * 로그인 성공 시 반환되는 JWT 토큰 정보 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "로그인 응답 정보") -public class LoginResponse { - - @Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") - private String accessToken; - - @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") - private String refreshToken; - - @Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") - private long expiresIn; -} diff --git a/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java deleted file mode 100644 index efd99d8..0000000 --- a/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.won.smarketing.member.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.Email; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; - -/** - * 회원가입 요청 DTO - * 회원가입 시 필요한 정보를 담는 데이터 전송 객체 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "회원가입 요청 정보") -public class RegisterRequest { - - @Schema(description = "사용자 ID", example = "testuser", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") - @Size(min = 4, max = 20, message = "사용자 ID는 4자 이상 20자 이하여야 합니다.") - @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 가능합니다.") - private String userId; - - @Schema(description = "패스워드", example = "password123!", required = true) - @NotBlank(message = "패스워드는 필수입니다.") - private String password; - - @Schema(description = "이름", example = "홍길동", required = true) - @NotBlank(message = "이름은 필수입니다.") - @Size(max = 100, message = "이름은 100자 이하여야 합니다.") - private String name; - - @Schema(description = "사업자 번호", example = "123-45-67890", required = true) - @NotBlank(message = "사업자 번호는 필수입니다.") - @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") - private String businessNumber; - - @Schema(description = "이메일", example = "test@example.com", required = true) - @NotBlank(message = "이메일은 필수입니다.") - @Email(message = "올바른 이메일 형식이 아닙니다.") - private String email; -} diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java deleted file mode 100644 index a0cbf85..0000000 --- a/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.won.smarketing.member.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 토큰 응답 DTO - * 토큰 갱신 시 반환되는 새로운 JWT 토큰 정보 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "토큰 응답 정보") -public class TokenResponse { - - @Schema(description = "새로운 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") - private String accessToken; - - @Schema(description = "새로운 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") - private String refreshToken; - - @Schema(description = "토큰 만료 시간 (밀리초)", example = "900000") - private long expiresIn; -} diff --git a/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java deleted file mode 100644 index 5c56b39..0000000 --- a/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.won.smarketing.member.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.List; - -/** - * 유효성 검증 응답 DTO - * 패스워드 유효성 검증 결과 정보 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "유효성 검증 응답") -public class ValidationResponse { - - @Schema(description = "유효성 여부", example = "true") - private boolean isValid; - - @Schema(description = "검증 결과 메시지", example = "유효한 패스워드입니다.") - private String message; - - @Schema(description = "오류 목록") - private List errors; -} diff --git a/member/src/main/java/com/won/smarketing/member/entity/Member.java b/member/src/main/java/com/won/smarketing/member/entity/Member.java deleted file mode 100644 index d3adcc1..0000000 --- a/member/src/main/java/com/won/smarketing/member/entity/Member.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.won.smarketing.member.entity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 회원 정보를 나타내는 엔티티 - * 사용자 ID, 패스워드, 이름, 사업자 번호, 이메일 정보 저장 - */ -@Entity -@Table(name = "members") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Member { - - /** - * 회원 고유 식별자 - */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /** - * 사용자 ID (로그인용) - */ - @Column(name = "user_id", unique = true, nullable = false, length = 50) - private String userId; - - /** - * 암호화된 패스워드 - */ - @Column(name = "password", nullable = false) - private String password; - - /** - * 회원 이름 - */ - @Column(name = "name", nullable = false, length = 100) - private String name; - - /** - * 사업자 번호 - */ - @Column(name = "business_number", unique = true, nullable = false, length = 20) - private String businessNumber; - - /** - * 이메일 주소 - */ - @Column(name = "email", unique = true, nullable = false) - private String email; - - /** - * 회원 생성 시각 - */ - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - /** - * 회원 정보 수정 시각 - */ - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - /** - * 엔티티 저장 전 실행되는 메서드 - * 생성 시각과 수정 시각을 현재 시각으로 설정 - */ - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - /** - * 엔티티 업데이트 전 실행되는 메서드 - * 수정 시각을 현재 시각으로 갱신 - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } -} diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java deleted file mode 100644 index 8413aed..0000000 --- a/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java +++ /dev/null @@ -1,131 +0,0 @@ -package com.won.smarketing.member.service; - -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; -import com.won.smarketing.common.security.JwtTokenProvider; -import com.won.smarketing.member.dto.LoginRequest; -import com.won.smarketing.member.dto.LoginResponse; -import com.won.smarketing.member.dto.TokenResponse; -import com.won.smarketing.member.entity.Member; -import com.won.smarketing.member.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.concurrent.TimeUnit; - -/** - * 인증/인가 서비스 구현체 - * 로그인, 로그아웃, 토큰 갱신 기능 구현 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AuthServiceImpl implements AuthService { - - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - private final JwtTokenProvider jwtTokenProvider; - private final RedisTemplate redisTemplate; - - private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; - - /** - * 로그인 인증 처리 - * - * @param request 로그인 요청 정보 - * @return JWT 토큰 정보 - */ - @Override - @Transactional - public LoginResponse login(LoginRequest request) { - // 사용자 조회 - Member member = memberRepository.findByUserId(request.getUserId()) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - - // 패스워드 검증 - if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { - throw new BusinessException(ErrorCode.INVALID_PASSWORD); - } - - // JWT 토큰 생성 - String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); - String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); - long expiresIn = jwtTokenProvider.getAccessTokenValidityTime(); - - // Refresh Token을 Redis에 저장 - String refreshTokenKey = REFRESH_TOKEN_PREFIX + member.getUserId(); - redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, - jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS); - - return LoginResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .expiresIn(expiresIn) - .build(); - } - - /** - * 로그아웃 처리 - * - * @param refreshToken 무효화할 Refresh Token - */ - @Override - @Transactional - public void logout(String refreshToken) { - // 토큰에서 사용자 ID 추출 - String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); - - // Redis에서 Refresh Token 삭제 - String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId; - redisTemplate.delete(refreshTokenKey); - } - - /** - * 토큰 갱신 처리 - * - * @param refreshToken 갱신에 사용할 Refresh Token - * @return 새로운 JWT 토큰 정보 - */ - @Override - @Transactional - public TokenResponse refresh(String refreshToken) { - // Refresh Token 유효성 검증 - if (!jwtTokenProvider.validateToken(refreshToken)) { - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } - - // 토큰에서 사용자 ID 추출 - String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); - - // Redis에서 저장된 Refresh Token 확인 - String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId; - String storedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey); - - if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { - throw new BusinessException(ErrorCode.INVALID_TOKEN); - } - - // 사용자 존재 여부 확인 - Member member = memberRepository.findByUserId(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); - - // 새로운 토큰 생성 - String newAccessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); - String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); - long expiresIn = jwtTokenProvider.getAccessTokenValidityTime(); - - // 기존 Refresh Token 삭제 후 새로운 토큰 저장 - redisTemplate.delete(refreshTokenKey); - redisTemplate.opsForValue().set(refreshTokenKey, newRefreshToken, - jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS); - - return TokenResponse.builder() - .accessToken(newAccessToken) - .refreshToken(newRefreshToken) - .expiresIn(expiresIn) - .build(); - } -} diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java deleted file mode 100644 index c837763..0000000 --- a/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.won.smarketing.member.service; - -import com.won.smarketing.common.exception.BusinessException; -import com.won.smarketing.common.exception.ErrorCode; -import com.won.smarketing.member.dto.RegisterRequest; -import com.won.smarketing.member.dto.ValidationResponse; -import com.won.smarketing.member.entity.Member; -import com.won.smarketing.member.repository.MemberRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Pattern; - -/** - * 회원 관리 서비스 구현체 - * 회원가입, 중복 확인, 패스워드 유효성 검증 기능 구현 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class MemberServiceImpl implements MemberService { - - private final MemberRepository memberRepository; - private final PasswordEncoder passwordEncoder; - - // 패스워드 정규식: 영문, 숫자, 특수문자 각각 최소 1개 포함, 8자 이상 - private static final Pattern PASSWORD_PATTERN = Pattern.compile( - "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$" - ); - - /** - * 회원가입 처리 - * - * @param request 회원가입 요청 정보 - */ - @Override - @Transactional - public void register(RegisterRequest request) { - // 중복 ID 확인 - if (memberRepository.existsByUserId(request.getUserId())) { - throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); - } - - // 이메일 중복 확인 - if (memberRepository.existsByEmail(request.getEmail())) { - throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); - } - - // 사업자 번호 중복 확인 - if (memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { - throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); - } - - // 패스워드 암호화 - String encodedPassword = passwordEncoder.encode(request.getPassword()); - - // 회원 엔티티 생성 및 저장 - Member member = Member.builder() - .userId(request.getUserId()) - .password(encodedPassword) - .name(request.getName()) - .businessNumber(request.getBusinessNumber()) - .email(request.getEmail()) - .build(); - - memberRepository.save(member); - } - - /** - * 사용자 ID 중복 확인 - * - * @param userId 확인할 사용자 ID - * @return 중복 여부 (true: 중복, false: 사용 가능) - */ - @Override - public boolean checkDuplicate(String userId) { - return memberRepository.existsByUserId(userId); - } - - /** - * 패스워드 유효성 검증 - * - * @param password 검증할 패스워드 - * @return 유효성 검증 결과 - */ - @Override - public ValidationResponse validatePassword(String password) { - List errors = new ArrayList<>(); - boolean isValid = true; - - // 길이 검증 (8자 이상) - if (password.length() < 8) { - errors.add("패스워드는 8자 이상이어야 합니다."); - isValid = false; - } - - // 패턴 검증 (영문, 숫자, 특수문자 포함) - if (!PASSWORD_PATTERN.matcher(password).matches()) { - errors.add("패스워드는 영문, 숫자, 특수문자를 각각 최소 1개씩 포함해야 합니다."); - isValid = false; - } - - String message = isValid ? "유효한 패스워드입니다." : "패스워드가 보안 규칙을 만족하지 않습니다."; - - return ValidationResponse.builder() - .isValid(isValid) - .message(message) - .errors(errors) - .build(); - } -} diff --git a/member/src/main/resources/application.yml b/member/src/main/resources/application.yml deleted file mode 100644 index 7fb0c9a..0000000 --- a/member/src/main/resources/application.yml +++ /dev/null @@ -1,42 +0,0 @@ -server: - port: ${SERVER_PORT:8081} - servlet: - context-path: / - -spring: - application: - name: member-service - datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:memberdb} - 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 - data: - redis: - host: ${REDIS_HOST:localhost} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - timeout: 2000ms - -jwt: - secret-key: ${JWT_SECRET_KEY:mySecretKeyForJWTTokenGenerationThatShouldBeVeryLongAndSecure} - access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:900000} - refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000} - -springdoc: - swagger-ui: - path: /swagger-ui.html - operations-sorter: method - api-docs: - path: /api-docs - -logging: - level: - com.won.smarketing.member: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-ai/.gitignore b/smarketing-ai/.gitignore new file mode 100644 index 0000000..0ee64c1 --- /dev/null +++ b/smarketing-ai/.gitignore @@ -0,0 +1,23 @@ +# Python 가상환경 +venv/ +env/ +ENV/ +.venv/ +.env/ + +# Python 캐시 +__pycache__/ +*.py[cod] +*$py.class +*.so + +# 환경 변수 파일 +.env +.env.local +.env.*.local + +# IDE 설정 +.vscode/ +.idea/ +*.swp +*.swo \ No newline at end of file diff --git a/smarketing-ai/Dockerfile b/smarketing-ai/Dockerfile new file mode 100644 index 0000000..68c4544 --- /dev/null +++ b/smarketing-ai/Dockerfile @@ -0,0 +1,33 @@ +# 1. Dockerfile에 한글 폰트 추가 +FROM python:3.11-slim + +WORKDIR /app + +# 시스템 패키지 및 한글 폰트 설치 +RUN apt-get update && apt-get install -y \ + fonts-dejavu-core \ + fonts-noto-cjk \ + fonts-nanum \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# 추가 한글 폰트 다운로드 (선택사항) +RUN mkdir -p /app/fonts && \ + wget -O /app/fonts/NotoSansKR-Bold.ttf \ + "https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.ttf" + +# Python 의존성 설치 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY . . + +# 업로드 및 포스터 디렉토리 생성 +RUN mkdir -p uploads/temp uploads/posters templates/poster_templates + +# 포트 노출 +EXPOSE 5000 + +# 애플리케이션 실행 +CMD ["python", "app.py"] \ No newline at end of file diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py new file mode 100644 index 0000000..6a4b9d4 --- /dev/null +++ b/smarketing-ai/app.py @@ -0,0 +1,301 @@ +""" +AI 마케팅 서비스 Flask 애플리케이션 +점주를 위한 마케팅 콘텐츠 및 포스터 자동 생성 서비스 +""" +from flask import Flask, request, jsonify +from flask_cors import CORS +from werkzeug.utils import secure_filename +import os +from datetime import datetime +import traceback +from config.config import Config +from services.poster_service import PosterService +from services.sns_content_service import SnsContentService +from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest +from services.poster_service_v3 import PosterServiceV3 + + +def create_app(): + """Flask 애플리케이션 팩토리""" + app = Flask(__name__) + app.config.from_object(Config) + + # CORS 설정 + CORS(app) + + # 업로드 폴더 생성 + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'temp'), exist_ok=True) + os.makedirs('templates/poster_templates', exist_ok=True) + + # 서비스 인스턴스 생성 + poster_service = PosterService() + poster_service_v3 = PosterServiceV3() + sns_content_service = SnsContentService() + + @app.route('/health', methods=['GET']) + def health_check(): + """헬스 체크 API""" + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now().isoformat(), + 'service': 'AI Marketing Service' + }) + + # ===== 새로운 API 엔드포인트 ===== + + @app.route('/api/ai/sns', methods=['GET']) + def generate_sns_content(): + """ + SNS 게시물 생성 API (새로운 요구사항) + Java 서버에서 JSON 형태로 요청받아 HTML 형식의 게시물 반환 + """ + try: + # JSON 요청 데이터 검증 + if not request.is_json: + return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400 + + data = request.get_json() + if not data: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + + # 필수 필드 검증 + required_fields = ['title', 'category', 'contentType', 'platform', 'images'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 + + # 요청 모델 생성 + sns_request = SnsContentGetRequest( + title=data.get('title'), + category=data.get('category'), + contentType=data.get('contentType'), + platform=data.get('platform'), + images=data.get('images', []), + requirement=data.get('requirement'), + toneAndManner=data.get('toneAndManner'), + emotionIntensity=data.get('emotionIntensity'), + menuName=data.get('menuName'), + eventName=data.get('eventName'), + startDate=data.get('startDate'), + endDate=data.get('endDate') + ) + + # SNS 콘텐츠 생성 + result = sns_content_service.generate_sns_content(sns_request) + + if result['success']: + return jsonify({'content': result['content']}) + else: + return jsonify({'error': result['error']}), 500 + + except Exception as e: + app.logger.error(f"SNS 콘텐츠 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + + @app.route('/api/ai/poster', methods=['GET']) + def generate_poster_content(): + """ + 홍보 포스터 생성 API + 실제 제품 이미지를 포함한 분위기 배경 포스터 생성 + """ + try: + # JSON 요청 데이터 검증 + if not request.is_json: + return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400 + + data = request.get_json() + if not data: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + + # 필수 필드 검증 + required_fields = ['title', 'category', 'contentType', 'images'] + for field in required_fields: + if field not in data: + return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 + + # 날짜 변환 처리 + start_date = None + end_date = None + if data.get('startDate'): + try: + from datetime import datetime + start_date = datetime.strptime(data['startDate'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'startDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400 + + if data.get('endDate'): + try: + from datetime import datetime + end_date = datetime.strptime(data['endDate'], '%Y-%m-%d').date() + except ValueError: + return jsonify({'error': 'endDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400 + + # 요청 모델 생성 + poster_request = PosterContentGetRequest( + title=data.get('title'), + category=data.get('category'), + contentType=data.get('contentType'), + images=data.get('images', []), + photoStyle=data.get('photoStyle'), + requirement=data.get('requirement'), + toneAndManner=data.get('toneAndManner'), + emotionIntensity=data.get('emotionIntensity'), + menuName=data.get('menuName'), + eventName=data.get('eventName'), + startDate=start_date, + endDate=end_date + ) + + # 포스터 생성 (V3 사용) + result = poster_service_v3.generate_poster(poster_request) + + if result['success']: + return jsonify({ + 'content': result['content'], + 'analysis': result.get('analysis', {}) + }) + else: + return jsonify({'error': result['error']}), 500 + + except Exception as e: + app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + + # ===== 기존 API 엔드포인트 (하위 호환성) ===== + + @app.route('/api/content/generate', methods=['POST']) + def generate_content(): + """ + 마케팅 콘텐츠 생성 API (기존) + 점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성 + """ + try: + # 요청 데이터 검증 + if not request.form: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + + # 파일 업로드 처리 + uploaded_files = [] + if 'images' in request.files: + files = request.files.getlist('images') + for file in files: + if file and file.filename: + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + unique_filename = f"{timestamp}_{filename}" + file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename) + file.save(file_path) + uploaded_files.append(file_path) + + # 요청 모델 생성 + content_request = ContentRequest( + category=request.form.get('category', '음식'), + platform=request.form.get('platform', '인스타그램'), + image_paths=uploaded_files, + start_time=request.form.get('start_time'), + end_time=request.form.get('end_time'), + store_name=request.form.get('store_name', ''), + additional_info=request.form.get('additional_info', '') + ) + + # 콘텐츠 생성 + result = sns_content_service.generate_content(content_request) + + # 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + + return jsonify(result) + + except Exception as e: + # 에러 발생 시 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + app.logger.error(f"콘텐츠 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + + @app.route('/api/poster/generate', methods=['POST']) + def generate_poster(): + """ + 홍보 포스터 생성 API (기존) + 점주가 입력한 정보를 바탕으로 시각적 홍보 포스터 생성 + """ + try: + # 요청 데이터 검증 + if not request.form: + return jsonify({'error': '요청 데이터가 없습니다.'}), 400 + + # 파일 업로드 처리 + uploaded_files = [] + if 'images' in request.files: + files = request.files.getlist('images') + for file in files: + if file and file.filename: + filename = secure_filename(file.filename) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + unique_filename = f"{timestamp}_{filename}" + file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename) + file.save(file_path) + uploaded_files.append(file_path) + + # 요청 모델 생성 + poster_request = PosterRequest( + category=request.form.get('category', '음식'), + image_paths=uploaded_files, + start_time=request.form.get('start_time'), + end_time=request.form.get('end_time'), + store_name=request.form.get('store_name', ''), + event_title=request.form.get('event_title', ''), + discount_info=request.form.get('discount_info', ''), + additional_info=request.form.get('additional_info', '') + ) + + # 포스터 생성 + result = poster_service.generate_poster(poster_request) + + # 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + + return jsonify(result) + + except Exception as e: + # 에러 발생 시 임시 파일 정리 + for file_path in uploaded_files: + try: + os.remove(file_path) + except OSError: + pass + app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}") + app.logger.error(traceback.format_exc()) + return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500 + + @app.errorhandler(413) + def too_large(e): + """파일 크기 초과 에러 처리""" + return jsonify({'error': '업로드된 파일이 너무 큽니다. (최대 16MB)'}), 413 + + @app.errorhandler(500) + def internal_error(error): + """내부 서버 에러 처리""" + return jsonify({'error': '내부 서버 오류가 발생했습니다.'}), 500 + + return app + + +if __name__ == '__main__': + app = create_app() + app.run(host='0.0.0.0', port=5001, debug=True) diff --git a/smarketing-ai/config/__init__.py b/smarketing-ai/config/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/config/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/config/config.py b/smarketing-ai/config/config.py new file mode 100644 index 0000000..6b63540 --- /dev/null +++ b/smarketing-ai/config/config.py @@ -0,0 +1,30 @@ +""" +Flask 애플리케이션 설정 +환경변수를 통한 설정 관리 +""" +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + """애플리케이션 설정 클래스""" + # Flask 기본 설정 + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + # 파일 업로드 설정 + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads' + MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1024) # 16MB + # AI API 설정 + CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY') + OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') + # 지원되는 파일 확장자 + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} + # 템플릿 설정 + POSTER_TEMPLATE_PATH = 'templates/poster_templates' + + @staticmethod + def allowed_file(filename): + """업로드 파일 확장자 검증""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS diff --git a/smarketing-ai/models/__init__.py b/smarketing-ai/models/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/models/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py new file mode 100644 index 0000000..653dfb8 --- /dev/null +++ b/smarketing-ai/models/request_models.py @@ -0,0 +1,67 @@ +""" +요청 모델 정의 +API 요청 데이터 구조를 정의 +""" +from dataclasses import dataclass +from typing import List, Optional +from datetime import date + + +@dataclass +class SnsContentGetRequest: + """SNS 게시물 생성 요청 모델""" + title: str + category: str + contentType: str + platform: str + images: List[str] # 이미지 URL 리스트 + requirement: Optional[str] = None + toneAndManner: Optional[str] = None + emotionIntensity: Optional[str] = None + menuName: Optional[str] = None + eventName: Optional[str] = None + startDate: Optional[date] = None # LocalDate -> date + endDate: Optional[date] = None # LocalDate -> date + + +@dataclass +class PosterContentGetRequest: + """홍보 포스터 생성 요청 모델""" + title: str + category: str + contentType: str + images: List[str] # 이미지 URL 리스트 + photoStyle: Optional[str] = None + requirement: Optional[str] = None + toneAndManner: Optional[str] = None + emotionIntensity: Optional[str] = None + menuName: Optional[str] = None + eventName: Optional[str] = None + startDate: Optional[date] = None # LocalDate -> date + endDate: Optional[date] = None # LocalDate -> date + + +# 기존 모델들은 유지 +@dataclass +class ContentRequest: + """마케팅 콘텐츠 생성 요청 모델 (기존)""" + category: str + platform: str + image_paths: List[str] + start_time: Optional[str] = None + end_time: Optional[str] = None + store_name: Optional[str] = None + additional_info: Optional[str] = None + + +@dataclass +class PosterRequest: + """홍보 포스터 생성 요청 모델 (기존)""" + category: str + image_paths: List[str] + start_time: Optional[str] = None + end_time: Optional[str] = None + store_name: Optional[str] = None + event_title: Optional[str] = None + discount_info: Optional[str] = None + additional_info: Optional[str] = None diff --git a/smarketing-ai/requirements.txt b/smarketing-ai/requirements.txt new file mode 100644 index 0000000..4386f20 --- /dev/null +++ b/smarketing-ai/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.0 +Flask-CORS==4.0.0 +Pillow>=9.0.0 +requests==2.31.0 +anthropic>=0.25.0 +openai>=1.12.0 +python-dotenv==1.0.0 +Werkzeug==3.0.1 \ No newline at end of file diff --git a/smarketing-ai/services/__init__.py b/smarketing-ai/services/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/services/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py new file mode 100644 index 0000000..4894f8f --- /dev/null +++ b/smarketing-ai/services/poster_service.py @@ -0,0 +1,193 @@ +""" +포스터 생성 서비스 +OpenAI를 사용한 이미지 생성 (한글 프롬프트) +""" +import os +from typing import Dict, Any +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import PosterContentGetRequest + + +class PosterService: + """포스터 생성 서비스 클래스""" + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + # 포토 스타일별 프롬프트 + self.photo_styles = { + '미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용', + '모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃', + '빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감', + '컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러', + '우아한': '우아하고 고급스러운 느낌, 세련된 분위기', + '캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인' + } + + # 카테고리별 이미지 스타일 + self.category_styles = { + '음식': '음식 사진, 먹음직스러운, 맛있어 보이는', + '매장': '레스토랑 인테리어, 아늑한 분위기', + '이벤트': '홍보용 디자인, 눈길을 끄는', + '메뉴': '메뉴 디자인, 정리된 레이아웃', + '할인': '세일 포스터, 할인 디자인' + } + + # 톤앤매너별 디자인 스타일 + self.tone_styles = { + '친근한': '따뜻하고 친근한 색감, 부드러운 느낌', + '정중한': '격식 있고 신뢰감 있는 디자인', + '재미있는': '밝고 유쾌한 분위기, 활기찬 색상', + '전문적인': '전문적이고 신뢰할 수 있는 디자인' + } + + # 감정 강도별 디자인 + self.emotion_designs = { + '약함': '은은하고 차분한 색감, 절제된 표현', + '보통': '적당히 활기찬 색상, 균형잡힌 디자인', + '강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인' + } + + def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: + """ + 포스터 생성 (OpenAI 이미지 URL 반환) + """ + try: + # 참조 이미지 분석 (있는 경우) + image_analysis = self._analyze_reference_images(request.images) + + # 포스터 생성 프롬프트 생성 + prompt = self._create_poster_prompt(request, image_analysis) + + # OpenAI로 이미지 생성 + image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024") + + return { + 'success': True, + 'content': image_url + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]: + """ + 참조 이미지들 분석 + """ + if not image_urls: + return {'total_images': 0, 'results': []} + + analysis_results = [] + temp_files = [] + + try: + for image_url in image_urls: + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + + try: + # 이미지 분석 + image_description = self.ai_client.analyze_image(temp_path) + # 색상 분석 + colors = self.image_processor.analyze_colors(temp_path, 3) + + analysis_results.append({ + 'url': image_url, + 'description': image_description, + 'dominant_colors': colors + }) + except Exception as e: + analysis_results.append({ + 'url': image_url, + 'error': str(e) + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _create_poster_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str: + """ + 포스터 생성을 위한 AI 프롬프트 생성 (한글) + """ + # 기본 스타일 설정 + photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인') + category_style = self.category_styles.get(request.category, '홍보용 디자인') + tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌') + emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인') + + # 참조 이미지 설명 + reference_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + reference_descriptions.append(result['description']) + + # 색상 정보 + color_info = "" + if image_analysis.get('results'): + colors = image_analysis['results'][0].get('dominant_colors', []) + if colors: + color_info = f"참조 색상 팔레트: {colors[:3]}을 활용한 조화로운 색감" + + prompt = f""" +한국의 음식점/카페를 위한 전문적인 홍보 포스터를 디자인해주세요. + +**메인 콘텐츠:** +- 제목: "{request.title}" +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} + +**디자인 스타일 요구사항:** +- 포토 스타일: {photo_style} +- 카테고리 스타일: {category_style} +- 톤앤매너: {tone_style} +- 감정 강도: {emotion_design} + +**메뉴 정보:** +- 메뉴명: {request.menuName or '없음'} + +**이벤트 정보:** +- 이벤트명: {request.eventName or '특별 프로모션'} +- 시작일: {request.startDate or '지금'} +- 종료일: {request.endDate or '한정 기간'} + +**특별 요구사항:** +{request.requirement or '눈길을 끄는 전문적인 디자인'} + +**참조 이미지 설명:** +{chr(10).join(reference_descriptions) if reference_descriptions else '참조 이미지 없음'} + +{color_info} + +**디자인 가이드라인:** +- 한국 음식점/카페에 적합한 깔끔하고 현대적인 레이아웃 +- 한글 텍스트 요소를 자연스럽게 포함 +- 가독성이 좋은 전문적인 타이포그래피 +- 명확한 대비로 읽기 쉽게 구성 +- 소셜미디어 공유에 적합한 크기 +- 저작권이 없는 오리지널 디자인 +- 음식점에 어울리는 맛있어 보이는 색상 조합 +- 고객의 시선을 끄는 매력적인 비주얼 + +고객들이 음식점을 방문하고 싶게 만드는 시각적으로 매력적인 포스터를 만들어주세요. +텍스트는 한글로, 전체적인 분위기는 한국적 감성에 맞게 디자인해주세요. +""" + return prompt diff --git a/smarketing-ai/services/poster_service_v2.py b/smarketing-ai/services/poster_service_v2.py new file mode 100644 index 0000000..f70a0d5 --- /dev/null +++ b/smarketing-ai/services/poster_service_v2.py @@ -0,0 +1,382 @@ +""" +하이브리드 포스터 생성 서비스 +DALL-E: 텍스트 없는 아름다운 배경 생성 +PIL: 완벽한 한글 텍스트 오버레이 +""" +import os +from typing import Dict, Any +from datetime import datetime +from PIL import Image, ImageDraw, ImageFont, ImageEnhance +import requests +import io +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import PosterContentGetRequest + + +class PosterServiceV2: + """하이브리드 포스터 생성 서비스""" + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: + """ + 하이브리드 포스터 생성 + 1. DALL-E로 텍스트 없는 배경 생성 + 2. PIL로 완벽한 한글 텍스트 오버레이 + """ + try: + # 1. 참조 이미지 분석 + image_analysis = self._analyze_reference_images(request.images) + + # 2. DALL-E로 텍스트 없는 배경 생성 + background_prompt = self._create_background_only_prompt(request, image_analysis) + background_url = self.ai_client.generate_image_with_openai(background_prompt, "1024x1024") + + # 3. 배경 이미지 다운로드 + background_image = self._download_and_load_image(background_url) + + # 4. AI로 텍스트 컨텐츠 생성 + text_content = self._generate_text_content(request) + + # 5. PIL로 한글 텍스트 오버레이 + final_poster = self._add_perfect_korean_text(background_image, text_content, request) + + # 6. 최종 이미지 저장 + poster_url = self._save_final_poster(final_poster) + + return { + 'success': True, + 'content': poster_url + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _create_background_only_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str: + """텍스트 완전 제외 배경 전용 프롬프트""" + + # 참조 이미지 설명 + reference_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + reference_descriptions.append(result['description']) + + prompt = f""" +Create a beautiful text-free background design for a Korean restaurant promotional poster. + +ABSOLUTE REQUIREMENTS: +- NO TEXT, NO LETTERS, NO WORDS, NO CHARACTERS of any kind +- Pure visual background design only +- Professional Korean food business aesthetic +- Leave clear areas for text overlay (top 20% and bottom 30%) + +DESIGN STYLE: +- Category: {request.category} themed design +- Photo Style: {request.photoStyle or 'modern'} aesthetic +- Mood: {request.toneAndManner or 'friendly'} atmosphere +- Intensity: {request.emotionIntensity or 'medium'} visual impact + +VISUAL ELEMENTS TO INCLUDE: +- Korean traditional patterns or modern geometric designs +- Food-related visual elements (ingredients, cooking utensils, abstract food shapes) +- Warm, appetizing color palette +- Professional restaurant branding feel +- Clean, modern layout structure + +REFERENCE CONTEXT: +{chr(10).join(reference_descriptions) if reference_descriptions else 'Clean, professional food business design'} + +COMPOSITION: +- Central visual focus area +- Clear top section for main title +- Clear bottom section for details +- Balanced negative space +- High-end restaurant poster aesthetic + +STRICTLY AVOID: +- Any form of text (Korean, English, numbers, symbols) +- Menu boards or signs with text +- Price displays +- Written content of any kind +- Typography elements + +Create a premium, appetizing background that will make customers want to visit the restaurant. +Focus on visual appeal, color harmony, and professional food business branding. +""" + return prompt + + def _download_and_load_image(self, image_url: str) -> Image.Image: + """이미지 URL에서 PIL 이미지로 로드""" + response = requests.get(image_url, timeout=30) + response.raise_for_status() + return Image.open(io.BytesIO(response.content)) + + def _generate_text_content(self, request: PosterContentGetRequest) -> Dict[str, str]: + """AI로 포스터 텍스트 컨텐츠 생성""" + prompt = f""" +한국 음식점 홍보 포스터용 텍스트를 생성해주세요. + +포스터 정보: +- 제목: {request.title} +- 카테고리: {request.category} +- 메뉴명: {request.menuName or ''} +- 이벤트명: {request.eventName or ''} +- 시작일: {request.startDate or ''} +- 종료일: {request.endDate or ''} + +다음 형식으로만 답변해주세요: +메인제목: [임팩트 있는 제목 8자 이내] +서브제목: [설명 문구 15자 이내] +기간정보: [기간 표시] +액션문구: [행동유도 8자 이내] +""" + + try: + ai_response = self.ai_client.generate_text(prompt, max_tokens=150) + return self._parse_text_content(ai_response, request) + except: + return self._create_fallback_content(request) + + def _parse_text_content(self, ai_response: str, request: PosterContentGetRequest) -> Dict[str, str]: + """AI 응답 파싱""" + content = { + 'main_title': request.title[:8], + 'sub_title': '', + 'period_info': '', + 'action_text': '지금 확인!' + } + + lines = ai_response.split('\n') + for line in lines: + line = line.strip() + if '메인제목:' in line: + content['main_title'] = line.split('메인제목:')[1].strip() + elif '서브제목:' in line: + content['sub_title'] = line.split('서브제목:')[1].strip() + elif '기간정보:' in line: + content['period_info'] = line.split('기간정보:')[1].strip() + elif '액션문구:' in line: + content['action_text'] = line.split('액션문구:')[1].strip() + + return content + + def _create_fallback_content(self, request: PosterContentGetRequest) -> Dict[str, str]: + """AI 실패시 기본 컨텐츠""" + return { + 'main_title': request.title[:8] if request.title else '특별 이벤트', + 'sub_title': request.eventName or request.menuName or '맛있는 음식', + 'period_info': f"{request.startDate} ~ {request.endDate}" if request.startDate and request.endDate else '', + 'action_text': '지금 방문!' + } + + def _add_perfect_korean_text(self, background: Image.Image, content: Dict[str, str], request: PosterContentGetRequest) -> Image.Image: + """완벽한 한글 텍스트 오버레이""" + + # 배경 이미지 복사 + poster = background.copy() + draw = ImageDraw.Draw(poster) + width, height = poster.size + + # 한글 폰트 로드 (여러 경로 시도) + fonts = self._load_korean_fonts() + + # 텍스트 색상 결정 (배경 분석 기반) + text_color = self._determine_text_color(background) + shadow_color = (0, 0, 0) if text_color == (255, 255, 255) else (255, 255, 255) + + # 1. 메인 제목 (상단) + if content['main_title']: + self._draw_text_with_effects( + draw, content['main_title'], + fonts['title'], text_color, shadow_color, + width // 2, height * 0.15, 'center' + ) + + # 2. 서브 제목 + if content['sub_title']: + self._draw_text_with_effects( + draw, content['sub_title'], + fonts['subtitle'], text_color, shadow_color, + width // 2, height * 0.75, 'center' + ) + + # 3. 기간 정보 + if content['period_info']: + self._draw_text_with_effects( + draw, content['period_info'], + fonts['small'], text_color, shadow_color, + width // 2, height * 0.82, 'center' + ) + + # 4. 액션 문구 (강조 배경) + if content['action_text']: + self._draw_call_to_action( + draw, content['action_text'], + fonts['subtitle'], width, height + ) + + return poster + + def _load_korean_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """한글 폰트 로드 (여러 경로 시도)""" + font_paths = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/System/Library/Fonts/Arial.ttf", # macOS + "C:/Windows/Fonts/arial.ttf", # Windows + "/usr/share/fonts/TTF/arial.ttf" # Linux + ] + + fonts = {} + + for font_path in font_paths: + try: + fonts['title'] = ImageFont.truetype(font_path, 60) + fonts['subtitle'] = ImageFont.truetype(font_path, 32) + fonts['small'] = ImageFont.truetype(font_path, 24) + break + except: + continue + + # 폰트 로드 실패시 기본 폰트 + if not fonts: + fonts = { + 'title': ImageFont.load_default(), + 'subtitle': ImageFont.load_default(), + 'small': ImageFont.load_default() + } + + return fonts + + def _determine_text_color(self, image: Image.Image) -> tuple: + """배경 이미지 분석하여 텍스트 색상 결정""" + # 이미지 상단과 하단의 평균 밝기 계산 + top_region = image.crop((0, 0, image.width, image.height // 4)) + bottom_region = image.crop((0, image.height * 3 // 4, image.width, image.height)) + + def get_brightness(img_region): + grayscale = img_region.convert('L') + pixels = list(grayscale.getdata()) + return sum(pixels) / len(pixels) + + top_brightness = get_brightness(top_region) + bottom_brightness = get_brightness(bottom_region) + avg_brightness = (top_brightness + bottom_brightness) / 2 + + # 밝으면 검은색, 어두우면 흰색 텍스트 + return (50, 50, 50) if avg_brightness > 128 else (255, 255, 255) + + def _draw_text_with_effects(self, draw, text, font, color, shadow_color, x, y, align='center'): + """그림자 효과가 있는 텍스트 그리기""" + if not text: + return + + # 텍스트 크기 계산 + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 위치 조정 + if align == 'center': + x = x - text_width // 2 + + # 배경 박스 (가독성 향상) + padding = 10 + box_coords = [ + x - padding, y - padding, + x + text_width + padding, y + text_height + padding + ] + draw.rectangle(box_coords, fill=(0, 0, 0, 180)) + + # 그림자 효과 + shadow_offset = 2 + draw.text((x + shadow_offset, y + shadow_offset), text, fill=shadow_color, font=font) + + # 메인 텍스트 + draw.text((x, y), text, fill=color, font=font) + + def _draw_call_to_action(self, draw, text, font, width, height): + """강조된 액션 버튼 스타일 텍스트""" + if not text: + return + + # 버튼 위치 (하단 중앙) + button_y = height * 0.88 + + # 텍스트 크기 + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # 버튼 배경 + button_width = text_width + 40 + button_height = text_height + 20 + button_x = (width - button_width) // 2 + + # 버튼 그리기 + button_coords = [ + button_x, button_y - 10, + button_x + button_width, button_y + button_height + ] + draw.rounded_rectangle(button_coords, radius=25, fill=(255, 107, 107)) + + # 텍스트 그리기 + text_x = (width - text_width) // 2 + text_y = button_y + 5 + draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font) + + def _save_final_poster(self, poster: Image.Image) -> str: + """최종 포스터 저장""" + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"hybrid_poster_{timestamp}.png" + filepath = os.path.join('uploads', 'temp', filename) + + os.makedirs(os.path.dirname(filepath), exist_ok=True) + poster.save(filepath, 'PNG', quality=95) + + return f"http://localhost:5001/uploads/temp/{filename}" + + def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]: + """참조 이미지 분석 (기존 코드와 동일)""" + if not image_urls: + return {'total_images': 0, 'results': []} + + analysis_results = [] + temp_files = [] + + try: + for image_url in image_urls: + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + try: + image_description = self.ai_client.analyze_image(temp_path) + colors = self.image_processor.analyze_colors(temp_path, 3) + analysis_results.append({ + 'url': image_url, + 'description': image_description, + 'dominant_colors': colors + }) + except Exception as e: + analysis_results.append({ + 'url': image_url, + 'error': str(e) + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass \ No newline at end of file diff --git a/smarketing-ai/services/poster_service_v3.py b/smarketing-ai/services/poster_service_v3.py new file mode 100644 index 0000000..d55662c --- /dev/null +++ b/smarketing-ai/services/poster_service_v3.py @@ -0,0 +1,204 @@ +""" +포스터 생성 서비스 V3 +OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1개 + 프롬프트 내 예시 링크 10개) +""" +import os +from typing import Dict, Any, List +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import PosterContentGetRequest + + +class PosterServiceV3: + """포스터 생성 서비스 V3 클래스""" + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + # Azure Blob Storage 예시 이미지 링크 10개 (카페 음료 관련) + self.example_images = [ + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example1.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example2.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example3.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example4.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example5.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example6.png", + "https://stdigitalgarage02.blob.core.windows.net/ai-content/example7.png" + ] + + # 포토 스타일별 프롬프트 + self.photo_styles = { + '미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용', + '모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃', + '빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감', + '컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러', + '우아한': '우아하고 고급스러운 느낌, 세련된 분위기', + '캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인' + } + + # 카테고리별 이미지 스타일 + self.category_styles = { + '음식': '음식 사진, 먹음직스러운, 맛있어 보이는', + '매장': '레스토랑 인테리어, 아늑한 분위기', + '이벤트': '홍보용 디자인, 눈길을 끄는', + '메뉴': '메뉴 디자인, 정리된 레이아웃', + '할인': '세일 포스터, 할인 디자인', + '음료': '시원하고 상쾌한, 맛있어 보이는 음료' + } + + # 톤앤매너별 디자인 스타일 + self.tone_styles = { + '친근한': '따뜻하고 친근한 색감, 부드러운 느낌', + '정중한': '격식 있고 신뢰감 있는 디자인', + '재미있는': '밝고 유쾌한 분위기, 활기찬 색상', + '전문적인': '전문적이고 신뢰할 수 있는 디자인' + } + + # 감정 강도별 디자인 + self.emotion_designs = { + '약함': '은은하고 차분한 색감, 절제된 표현', + '보통': '적당히 활기찬 색상, 균형잡힌 디자인', + '강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인' + } + + def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]: + """ + 포스터 생성 (메인 이미지 1개 분석 + 예시 링크 10개 프롬프트 제공) + """ + try: + # 메인 이미지 확인 + if not request.images: + return {'success': False, 'error': '메인 메뉴 이미지가 제공되지 않았습니다.'} + + main_image_url = request.images[0] # 첫 번째 이미지가 메인 메뉴 + + # 메인 이미지 분석 + main_image_analysis = self._analyze_main_image(main_image_url) + + # 포스터 생성 프롬프트 생성 (예시 링크 10개 포함) + prompt = self._create_poster_prompt_v3(request, main_image_analysis) + + # OpenAI로 이미지 생성 + image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024") + + return { + 'success': True, + 'content': image_url, + 'analysis': { + 'main_image': main_image_analysis, + 'example_images_used': len(self.example_images) + } + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _analyze_main_image(self, image_url: str) -> Dict[str, Any]: + """ + 메인 메뉴 이미지 분석 + """ + temp_files = [] + try: + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + + # 이미지 분석 + image_info = self.image_processor.get_image_info(temp_path) + image_description = self.ai_client.analyze_image(temp_path) + colors = self.image_processor.analyze_colors(temp_path, 5) + + return { + 'url': image_url, + 'info': image_info, + 'description': image_description, + 'dominant_colors': colors, + 'is_food': self.image_processor.is_food_image(temp_path) + } + else: + return { + 'url': image_url, + 'error': '이미지 다운로드 실패' + } + + except Exception as e: + return { + 'url': image_url, + 'error': str(e) + } + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _create_poster_prompt_v3(self, request: PosterContentGetRequest, + main_analysis: Dict[str, Any]) -> str: + """ + V3 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 10개 포함) + """ + # 기본 스타일 설정 + photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인') + category_style = self.category_styles.get(request.category, '홍보용 디자인') + tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌') + emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인') + + # 메인 이미지 정보 활용 + main_description = main_analysis.get('description', '맛있는 음식') + main_colors = main_analysis.get('dominant_colors', []) + main_image_url = main_analysis.get('url', '') + image_info = main_analysis.get('info', {}) + is_food = main_analysis.get('is_food', False) + + # 이미지 크기 및 비율 정보 + aspect_ratio = image_info.get('aspect_ratio', 1.0) if image_info else 1.0 + image_orientation = "가로형" if aspect_ratio > 1.2 else "세로형" if aspect_ratio < 0.8 else "정사각형" + + # 색상 정보를 텍스트로 변환 + color_description = "" + if main_colors: + color_rgb = main_colors[:3] # 상위 3개 색상 + color_description = f"주요 색상 RGB 값: {color_rgb}를 기반으로 한 조화로운 색감" + + # 예시 이미지 링크들을 문자열로 변환 + example_links = "\n".join([f"- {link}" for link in self.example_images]) + + prompt = f""" + 메인 이미지 URL을 참조하여, "글이 없는" 심플한 카페 포스터를 디자인해주세요. + + **핵심 기준 이미지:** + 메인 이미지 URL: {main_image_url} + 이 이미지 URL에 들어가 이미지를 다운로드 후, 이 이미지를 그대로 반영한 채 홍보 포스터를 디자인해주세요. + 심플한 배경이 중요합니다. + AI가 생성하지 않은 것처럼 현실적인 요소를 반영해주세요. + + **절대 필수 조건:** + - 어떤 형태의 텍스트, 글자, 문자, 숫자도 절대 포함하지 말 것!!!! - 가장 중요 + - 위의 메인 이미지를 임의 변경 없이, 포스터의 중심 요소로 포함할 것 + - 하나의 포스터만 생성해주세요 + - 메인 이미지의 색감과 분위기를 살려서 심플한 포스터 디자인 + - 메인 이미지가 돋보이도록 배경과 레이아웃 구성 + - 확실하지도 않은 문자 절대 생성 x + + **특별 요구사항:** + {request.requirement} + + + + **반드시 제외할 요소:** + - 모든 형태의 텍스트 (한글, 영어, 숫자, 기호) + - 메뉴판, 가격표, 간판 + - 글자가 적힌 모든 요소 + - 브랜드 로고나 문자 + + """ + return prompt diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py new file mode 100644 index 0000000..24fa7a5 --- /dev/null +++ b/smarketing-ai/services/sns_content_service.py @@ -0,0 +1,646 @@ +""" +SNS 콘텐츠 생성 서비스 (플랫폼 특화 개선) +""" +import os +from typing import Dict, Any, List, Tuple +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import SnsContentGetRequest + + +class SnsContentService: + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + # 플랫폼별 콘텐츠 특성 정의 (대폭 개선) + self.platform_specs = { + '인스타그램': { + 'max_length': 2200, + 'hashtag_count': 15, + 'style': '감성적이고 시각적', + 'format': '짧은 문장, 해시태그 활용', + 'content_structure': '후킹 문장 → 스토리텔링 → 행동 유도 → 해시태그', + 'writing_tips': [ + '첫 문장으로 관심 끌기', + '이모티콘을 적절히 활용', + '줄바꿈으로 가독성 높이기', + '개성 있는 말투 사용', + '팔로워와의 소통 유도' + ], + 'hashtag_strategy': [ + '브랜딩 해시태그 포함', + '지역 기반 해시태그', + '트렌딩 해시태그 활용', + '음식 관련 인기 해시태그', + '감정 표현 해시태그' + ], + 'call_to_action': ['팔로우', '댓글', '저장', '공유', '방문'] + }, + '네이버 블로그': { + 'max_length': 3000, + 'hashtag_count': 10, + 'style': '정보성과 친근함', + 'format': '구조화된 내용, 상세 설명', + 'content_structure': '제목 → 인트로 → 본문(구조화) → 마무리', + 'writing_tips': [ + '검색 키워드 자연스럽게 포함', + '단락별로 소제목 활용', + '구체적인 정보 제공', + '후기/리뷰 형식 활용', + '지역 정보 상세히 기술' + ], + 'seo_keywords': [ + '맛집', '리뷰', '추천', '후기', + '메뉴', '가격', '위치', '분위기', + '데이트', '모임', '가족', '혼밥' + ], + 'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'], + 'image_placement_strategy': [ + '매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기', + '텍스트 2-3문장마다 이미지 배치', + '이미지 설명은 간결하고 매력적으로', + '마지막에 대표 이미지로 마무리' + ] + } + } + + # 톤앤매너별 스타일 (플랫폼별 세분화) + self.tone_styles = { + '친근한': { + '인스타그램': '반말, 친구같은 느낌, 이모티콘 많이 사용', + '네이버 블로그': '존댓말이지만 따뜻하고 친근한 어조' + }, + '정중한': { + '인스타그램': '정중하지만 접근하기 쉬운 어조', + '네이버 블로그': '격식 있고 신뢰감 있는 리뷰 스타일' + }, + '재미있는': { + '인스타그램': '유머러스하고 트렌디한 표현', + '네이버 블로그': '재미있는 에피소드가 포함된 후기' + }, + '전문적인': { + '인스타그램': '전문성을 어필하되 딱딱하지 않게', + '네이버 블로그': '전문가 관점의 상세한 분석과 평가' + } + } + + # 카테고리별 플랫폼 특화 키워드 + self.category_keywords = { + '음식': { + '인스타그램': ['#맛스타그램', '#음식스타그램', '#먹스타그램', '#맛집', '#foodstagram'], + '네이버 블로그': ['맛집 리뷰', '음식 후기', '메뉴 추천', '맛집 탐방', '식당 정보'] + }, + '매장': { + '인스타그램': ['#카페스타그램', '#인테리어', '#분위기맛집', '#데이트장소'], + '네이버 블로그': ['카페 추천', '분위기 좋은 곳', '인테리어 구경', '모임장소'] + }, + '이벤트': { + '인스타그램': ['#이벤트', '#프로모션', '#할인', '#특가'], + '네이버 블로그': ['이벤트 소식', '할인 정보', '프로모션 안내', '특별 혜택'] + } + } + + # 감정 강도별 표현 + self.emotion_levels = { + '약함': '은은하고 차분한 표현', + '보통': '적당히 활기찬 표현', + '강함': '매우 열정적이고 강렬한 표현' + } + + # 이미지 타입 분류를 위한 키워드 + self.image_type_keywords = { + '매장외관': ['외관', '건물', '간판', '입구', '외부'], + '인테리어': ['내부', '인테리어', '좌석', '테이블', '분위기', '장식'], + '메뉴판': ['메뉴', '가격', '메뉴판', '메뉴보드', 'menu'], + '음식': ['음식', '요리', '메뉴', '디저트', '음료', '플레이팅'], + '사람': ['사람', '고객', '직원', '사장', '요리사'], + '기타': ['기타', '일반', '전체'] + } + + def generate_sns_content(self, request: SnsContentGetRequest) -> Dict[str, Any]: + """ + SNS 콘텐츠 생성 (플랫폼별 특화) + """ + try: + # 이미지 다운로드 및 분석 + image_analysis = self._analyze_images_from_urls(request.images) + + # 네이버 블로그인 경우 이미지 배치 계획 생성 + image_placement_plan = None + if request.platform == '네이버 블로그': + image_placement_plan = self._create_image_placement_plan(image_analysis, request) + + # 플랫폼별 특화 프롬프트 생성 + prompt = self._create_platform_specific_prompt(request, image_analysis, image_placement_plan) + + # AI로 콘텐츠 생성 + generated_content = self.ai_client.generate_text(prompt, max_tokens=1500) + + # 플랫폼별 후처리 + processed_content = self._post_process_content(generated_content, request) + + # HTML 형식으로 포맷팅 + html_content = self._format_to_html(processed_content, request, image_placement_plan) + + result = { + 'success': True, + 'content': html_content + } + + # 네이버 블로그인 경우 이미지 배치 가이드라인 추가 + if request.platform == '네이버 블로그' and image_placement_plan: + result['image_placement_guide'] = image_placement_plan + + return result + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _analyze_images_from_urls(self, image_urls: list) -> Dict[str, Any]: + """ + URL에서 이미지를 다운로드하고 분석 (이미지 타입 분류 추가) + """ + analysis_results = [] + temp_files = [] + + try: + for i, image_url in enumerate(image_urls): + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + + # 이미지 분석 + try: + image_info = self.image_processor.get_image_info(temp_path) + image_description = self.ai_client.analyze_image(temp_path) + + # 이미지 타입 분류 + image_type = self._classify_image_type(image_description) + + analysis_results.append({ + 'index': i, + 'url': image_url, + 'info': image_info, + 'description': image_description, + 'type': image_type + }) + except Exception as e: + analysis_results.append({ + 'index': i, + 'url': image_url, + 'error': str(e), + 'type': '기타' + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _classify_image_type(self, description: str) -> str: + """ + 이미지 설명을 바탕으로 이미지 타입 분류 + """ + description_lower = description.lower() + + for image_type, keywords in self.image_type_keywords.items(): + for keyword in keywords: + if keyword in description_lower: + return image_type + + return '기타' + + def _create_image_placement_plan(self, image_analysis: Dict[str, Any], request: SnsContentGetRequest) -> Dict[ + str, Any]: + """ + 네이버 블로그용 이미지 배치 계획 생성 + """ + images = image_analysis.get('results', []) + if not images: + return None + + # 이미지 타입별 분류 + categorized_images = { + '매장외관': [], + '인테리어': [], + '메뉴판': [], + '음식': [], + '사람': [], + '기타': [] + } + + for img in images: + img_type = img.get('type', '기타') + categorized_images[img_type].append(img) + + # 블로그 구조에 따른 이미지 배치 계획 + placement_plan = { + 'structure': [ + { + 'section': '인트로', + 'description': '첫인상과 방문 동기', + 'recommended_images': [], + 'placement_guide': '매장 외관이나 대표적인 음식 사진으로 시작' + }, + { + 'section': '매장 정보', + 'description': '위치, 분위기, 인테리어 소개', + 'recommended_images': [], + 'placement_guide': '매장 외관 → 내부 인테리어 순서로 배치' + }, + { + 'section': '메뉴 소개', + 'description': '주문한 메뉴와 상세 후기', + 'recommended_images': [], + 'placement_guide': '메뉴판 → 실제 음식 사진 순서로 배치' + }, + { + 'section': '총평', + 'description': '재방문 의향과 추천 이유', + 'recommended_images': [], + 'placement_guide': '가장 매력적인 음식 사진이나 전체 분위기 사진' + } + ], + 'image_sequence': [], + 'usage_guide': [] + } + + # 각 섹션에 적절한 이미지 배정 + # 인트로: 매장외관 또는 대표 음식 + if categorized_images['매장외관']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1]) + elif categorized_images['음식']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['음식'][:1]) + + # 매장 정보: 외관 + 인테리어 + placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관']) + placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어']) + + # 메뉴 소개: 메뉴판 + 음식 + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판']) + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식']) + + # 총평: 남은 음식 사진 또는 기타 + remaining_food = [img for img in categorized_images['음식'] + if img not in placement_plan['structure'][2]['recommended_images']] + placement_plan['structure'][3]['recommended_images'].extend(remaining_food[:1]) + placement_plan['structure'][3]['recommended_images'].extend(categorized_images['기타'][:1]) + + # 전체 이미지 순서 생성 + for section in placement_plan['structure']: + for img in section['recommended_images']: + if img not in placement_plan['image_sequence']: + placement_plan['image_sequence'].append(img) + + # 사용 가이드 생성 + placement_plan['usage_guide'] = [ + "📸 이미지 배치 가이드라인:", + "1. 각 섹션마다 2-3문장의 설명 후 이미지 삽입", + "2. 이미지마다 간단한 설명 텍스트 추가", + "3. 음식 사진은 가장 맛있어 보이는 각도로 배치", + "4. 마지막에 전체적인 분위기를 보여주는 사진으로 마무리" + ] + + return placement_plan + + def _create_platform_specific_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any], + image_placement_plan: Dict[str, Any] = None) -> str: + """ + 플랫폼별 특화 프롬프트 생성 + """ + platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) + tone_style = self.tone_styles.get(request.toneAndManner, {}).get(request.platform, '친근하고 자연스러운 어조') + + # 이미지 설명 추출 + image_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + image_descriptions.append(result['description']) + + # 플랫폼별 특화 프롬프트 생성 + if request.platform == '인스타그램': + return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions) + elif request.platform == '네이버 블로그': + return self._create_naver_blog_prompt(request, platform_spec, tone_style, image_descriptions, + image_placement_plan) + else: + return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions) + + def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str, + image_descriptions: list) -> str: + """ + 인스타그램 특화 프롬프트 + """ + category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', []) + + prompt = f""" +당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요. + +**🎯 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '특별 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} + +**📱 인스타그램 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- 해시태그: {platform_spec['hashtag_count']}개 내외 +- 톤앤매너: {tone_style} + +**✨ 인스타그램 작성 가이드라인:** +{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} + +**📸 이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '시각적으로 매력적인 음식/매장 이미지'} + +**🏷️ 추천 해시태그 카테고리:** +- 기본 해시태그: {', '.join(category_hashtags[:5])} +- 브랜딩: #우리가게이름 (실제 가게명으로 대체) +- 지역: #강남맛집 #서울카페 (실제 위치로 대체) +- 감정: #행복한시간 #맛있다 #추천해요 + +**💡 콘텐츠 작성 지침:** +1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작 +2. 이모티콘을 적절히 활용하여 시각적 재미 추가 +3. 스토리텔링을 통해 감정적 연결 유도 +4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등) +5. 줄바꿈을 활용하여 가독성 향상 +6. 해시태그는 본문과 자연스럽게 연결되도록 배치 + +**특별 요구사항:** +{request.requirement or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'} + +인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요. +""" + return prompt + + def _create_naver_blog_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str, + image_descriptions: list, image_placement_plan: Dict[str, Any]) -> str: + """ + 네이버 블로그 특화 프롬프트 (이미지 배치 계획 포함) + """ + category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', []) + seo_keywords = platform_spec['seo_keywords'] + + # 이미지 배치 정보 추가 + image_placement_info = "" + if image_placement_plan: + image_placement_info = f""" + +**📸 이미지 배치 계획:** +{chr(10).join([f"- {section['section']}: {section['placement_guide']}" for section in image_placement_plan['structure']])} + +**이미지 사용 순서:** +{chr(10).join([f"{i + 1}. {img.get('description', 'Image')} (타입: {img.get('type', '기타')})" for i, img in enumerate(image_placement_plan.get('image_sequence', []))])} +""" + + prompt = f""" +당신은 네이버 블로그 맛집 리뷰 전문가입니다. 검색 최적화와 정보 제공을 중시하는 네이버 블로그 특성에 맞는 게시물을 작성해주세요. + +**📝 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '대표 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} + +**🔍 네이버 블로그 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- 톤앤매너: {tone_style} +- SEO 최적화 필수 + +**📚 블로그 작성 가이드라인:** +{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} + +**🖼️ 이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '상세한 음식/매장 정보'} + +{image_placement_info} + +**🔑 SEO 키워드 (자연스럽게 포함할 것):** +- 필수 키워드: {', '.join(seo_keywords[:8])} +- 카테고리 키워드: {', '.join(category_keywords[:5])} + +**📖 블로그 포스트 구조 (이미지 배치 포함):** +1. **인트로**: 방문 동기와 첫인상 + [IMAGE_1] 배치 +2. **매장 정보**: 위치, 운영시간, 분위기 + [IMAGE_2, IMAGE_3] 배치 +3. **메뉴 소개**: 주문한 메뉴와 상세 후기 + [IMAGE_4, IMAGE_5] 배치 +4. **총평**: 재방문 의향과 추천 이유 + [IMAGE_6] 배치 + +**💡 콘텐츠 작성 지침:** +1. 검색자의 궁금증을 해결하는 정보 중심 작성 +2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함 +3. 개인적인 경험과 솔직한 후기 작성 +4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시 +5. 이미지마다 간단한 설명 문구 추가 +6. 지역 정보와 접근성 정보 포함 + +**이미지 태그 사용법:** +- [IMAGE_1]: 첫 번째 이미지 배치 위치 +- [IMAGE_2]: 두 번째 이미지 배치 위치 +- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 + +**특별 요구사항:** +{request.requirement or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'} + +네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. +이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요. +""" + return prompt + + def _post_process_content(self, content: str, request: SnsContentGetRequest) -> str: + """ + 플랫폼별 후처리 + """ + if request.platform == '인스타그램': + return self._post_process_instagram(content, request) + elif request.platform == '네이버 블로그': + return self._post_process_naver_blog(content, request) + return content + + def _post_process_instagram(self, content: str, request: SnsContentGetRequest) -> str: + """ + 인스타그램 콘텐츠 후처리 + """ + import re + + # 해시태그 개수 조정 + hashtags = re.findall(r'#[\w가-힣]+', content) + if len(hashtags) > 15: + # 해시태그가 너무 많으면 중요도 순으로 15개만 유지 + all_hashtags = ' '.join(hashtags[:15]) + content = re.sub(r'#[\w가-힣]+', '', content) + content = content.strip() + '\n\n' + all_hashtags + + # 이모티콘이 부족하면 추가 + emoji_count = content.count('😊') + content.count('🍽️') + content.count('❤️') + content.count('✨') + if emoji_count < 3: + content = content.replace('!', '! 😊', 1) + + return content + + def _post_process_naver_blog(self, content: str, request: SnsContentGetRequest) -> str: + """ + 네이버 블로그 콘텐츠 후처리 + """ + # 구조화된 형태로 재구성 + if '📍' not in content and '🏷️' not in content: + # 이모티콘 기반 구조화가 없으면 추가 + lines = content.split('\n') + structured_content = [] + for line in lines: + if '위치' in line or '주소' in line: + line = f"📍 {line}" + elif '가격' in line or '메뉴' in line: + line = f"🏷️ {line}" + elif '분위기' in line or '인테리어' in line: + line = f"🏠 {line}" + structured_content.append(line) + content = '\n'.join(structured_content) + + return content + + def _format_to_html(self, content: str, request: SnsContentGetRequest, + image_placement_plan: Dict[str, Any] = None) -> str: + """ + 생성된 콘텐츠를 HTML 형식으로 포맷팅 (이미지 배치 포함) + """ + # 1. literal \n 문자열을 실제 줄바꿈으로 변환 + content = content.replace('\\n', '\n') + + # 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환 + if request.platform == '네이버 블로그' and image_placement_plan: + content = self._replace_image_tags_with_html(content, image_placement_plan, request.images) + + # 3. 실제 줄바꿈을
태그로 변환 + content = content.replace('\n', '
') + + # 4. 추가 정리: \r, 여러 공백 정리 + content = content.replace('\\r', '').replace('\r', '') + + # 5. 여러 개의
태그를 하나로 정리 + import re + content = re.sub(r'(
\s*){3,}', '

', content) + + # 6. 해시태그를 파란색으로 스타일링 + content = re.sub(r'(#[\w가-힣]+)', r'\1', content) + + # 플랫폼별 헤더 스타일 + platform_style = "" + if request.platform == '인스타그램': + platform_style = "background: linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);" + elif request.platform == '네이버 블로그': + platform_style = "background: linear-gradient(135deg, #1EC800 0%, #00B33C 100%);" + else: + platform_style = "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);" + + # 전체 HTML 구조 + html_content = f""" +
+
+

{request.platform} 게시물

+
+
+
+ {content} +
+ {self._add_metadata_html(request)} +
+
+ """ + return html_content + + def _replace_image_tags_with_html(self, content: str, image_placement_plan: Dict[str, Any], + image_urls: List[str]) -> str: + """ + 네이버 블로그 콘텐츠의 [IMAGE_X] 태그를 실제 이미지 HTML로 변환 + """ + import re + + # [IMAGE_X] 패턴 찾기 + image_tags = re.findall(r'\[IMAGE_(\d+)\]', content) + + for tag in image_tags: + image_index = int(tag) - 1 # 1-based to 0-based + + if image_index < len(image_urls): + image_url = image_urls[image_index] + + # 이미지 배치 계획에서 해당 이미지 정보 찾기 + image_info = None + for img in image_placement_plan.get('image_sequence', []): + if img.get('index') == image_index: + image_info = img + break + + # 이미지 설명 생성 + image_description = "" + if image_info: + description = image_info.get('description', '') + img_type = image_info.get('type', '기타') + + if img_type == '음식': + image_description = f"😋 {description}" + elif img_type == '매장외관': + image_description = f"🏪 {description}" + elif img_type == '인테리어': + image_description = f"🏠 {description}" + elif img_type == '메뉴판': + image_description = f"📋 {description}" + else: + image_description = f"📸 {description}" + + # HTML 이미지 태그로 변환 + image_html = f""" +
+ 이미지 +
+ {image_description} +
+
""" + + # 콘텐츠에서 태그 교체 + content = content.replace(f'[IMAGE_{tag}]', image_html) + + return content + + def _add_metadata_html(self, request: SnsContentGetRequest) -> str: + """ + 메타데이터를 HTML에 추가 + """ + metadata_html = '
' + + if request.menuName: + metadata_html += f'
메뉴: {request.menuName}
' + + if request.eventName: + metadata_html += f'
이벤트: {request.eventName}
' + + if request.startDate and request.endDate: + metadata_html += f'
기간: {request.startDate} ~ {request.endDate}
' + + metadata_html += f'
카테고리: {request.category}
' + metadata_html += f'
플랫폼: {request.platform}
' + metadata_html += f'
생성일: {datetime.now().strftime("%Y-%m-%d %H:%M")}
' + metadata_html += '
' + + return metadata_html diff --git a/smarketing-ai/utils/__init__.py b/smarketing-ai/utils/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/utils/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py new file mode 100644 index 0000000..7b1fe52 --- /dev/null +++ b/smarketing-ai/utils/ai_client.py @@ -0,0 +1,228 @@ +""" +AI 클라이언트 유틸리티 +Claude AI 및 OpenAI API 호출을 담당 +""" +import os +import base64 +import requests +from typing import Optional, List +import anthropic +import openai +from PIL import Image +import io + + +class AIClient: + """AI API 클라이언트 클래스""" + + def __init__(self): + """AI 클라이언트 초기화""" + self.claude_api_key = os.getenv('CLAUDE_API_KEY') + self.openai_api_key = os.getenv('OPENAI_API_KEY') + + # Claude 클라이언트 초기화 + if self.claude_api_key: + self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key) + else: + self.claude_client = None + + # OpenAI 클라이언트 초기화 + if self.openai_api_key: + self.openai_client = openai.OpenAI(api_key=self.openai_api_key) + else: + self.openai_client = None + + def download_image_from_url(self, image_url: str) -> str: + """ + URL에서 이미지를 다운로드하여 임시 파일로 저장 + Args: + image_url: 다운로드할 이미지 URL + Returns: + 임시 저장된 파일 경로 + """ + try: + response = requests.get(image_url, timeout=30) + response.raise_for_status() + + # 임시 파일로 저장 + import tempfile + import uuid + + file_extension = image_url.split('.')[-1] if '.' in image_url else 'jpg' + temp_filename = f"temp_{uuid.uuid4()}.{file_extension}" + temp_path = os.path.join('uploads', 'temp', temp_filename) + + # 디렉토리 생성 + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + + with open(temp_path, 'wb') as f: + f.write(response.content) + + return temp_path + + except Exception as e: + print(f"이미지 다운로드 실패 {image_url}: {e}") + return None + + def generate_image_with_openai(self, prompt: str, size: str = "1024x1024") -> str: + """ + OpenAI DALL-E를 사용하여 이미지 생성 + Args: + prompt: 이미지 생성 프롬프트 + size: 이미지 크기 (1024x1024, 1792x1024, 1024x1792) + Returns: + 생성된 이미지 URL + """ + try: + if not self.openai_client: + raise Exception("OpenAI API 키가 설정되지 않았습니다.") + + response = self.openai_client.images.generate( + model="dall-e-3", + prompt=prompt, + size="1024x1024", + quality="hd", # 고품질 설정 + style="vivid", # 또는 "natural" + n=1, + ) + + return response.data[0].url + + except Exception as e: + print(f"OpenAI 이미지 생성 실패: {e}") + raise Exception(f"이미지 생성 중 오류가 발생했습니다: {str(e)}") + + def generate_text(self, prompt: str, max_tokens: int = 1000) -> str: + """ + 텍스트 생성 (Claude 우선, 실패시 OpenAI 사용) + """ + # Claude AI 시도 + if self.claude_client: + try: + response = self.claude_client.messages.create( + model="claude-3-5-sonnet-20240620", + max_tokens=max_tokens, + messages=[ + {"role": "user", "content": prompt} + ] + ) + return response.content[0].text + except Exception as e: + print(f"Claude AI 호출 실패: {e}") + + # OpenAI 시도 + if self.openai_client: + try: + response = self.openai_client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "user", "content": prompt} + ], + max_tokens=max_tokens + ) + return response.choices[0].message.content + except Exception as e: + print(f"OpenAI 호출 실패: {e}") + + # 기본 응답 + return self._generate_fallback_content(prompt) + + def analyze_image(self, image_path: str) -> str: + """ + 이미지 분석 및 설명 생성 + """ + try: + # 이미지를 base64로 인코딩 + image_base64 = self._encode_image_to_base64(image_path) + + # Claude Vision API 시도 + if self.claude_client: + try: + response = self.claude_client.messages.create( + model="claude-3-5-sonnet-20240620", + max_tokens=500, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 음식이라면 맛있어 보이는 특징을, 매장이라면 분위기를, 이벤트라면 특별함을 강조해서 한국어로 50자 이내로 설명해주세요." + }, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": image_base64 + } + } + ] + } + ] + ) + return response.content[0].text + except Exception as e: + print(f"Claude 이미지 분석 실패: {e}") + + # OpenAI Vision API 시도 + if self.openai_client: + try: + response = self.openai_client.chat.completions.create( + model="gpt-4o", + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 한국어로 50자 이내로 설명해주세요." + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image_base64}" + } + } + ] + } + ], + max_tokens=300 + ) + return response.choices[0].message.content + except Exception as e: + print(f"OpenAI 이미지 분석 실패: {e}") + + except Exception as e: + print(f"이미지 분석 전체 실패: {e}") + + return "맛있고 매력적인 음식점의 특별한 순간" + + def _encode_image_to_base64(self, image_path: str) -> str: + """이미지 파일을 base64로 인코딩""" + with open(image_path, "rb") as image_file: + image = Image.open(image_file) + if image.width > 1024 or image.height > 1024: + image.thumbnail((1024, 1024), Image.Resampling.LANCZOS) + + if image.mode == 'RGBA': + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + image = background + + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + return base64.b64encode(img_buffer.getvalue()).decode('utf-8') + + def _generate_fallback_content(self, prompt: str) -> str: + """AI 서비스 실패시 기본 콘텐츠 생성""" + if "콘텐츠" in prompt or "게시글" in prompt: + return """안녕하세요! 오늘도 맛있는 하루 되세요 😊 + 우리 가게의 특별한 메뉴를 소개합니다! + 정성껏 준비한 음식으로 여러분을 맞이하겠습니다. + 많은 관심과 사랑 부탁드려요!""" + elif "포스터" in prompt: + return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!" + else: + return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다." diff --git a/smarketing-ai/utils/image_processor.py b/smarketing-ai/utils/image_processor.py new file mode 100644 index 0000000..176c10a --- /dev/null +++ b/smarketing-ai/utils/image_processor.py @@ -0,0 +1,166 @@ +""" +이미지 처리 유틸리티 +이미지 분석, 변환, 최적화 기능 제공 +""" +import os +from typing import Dict, Any, Tuple +from PIL import Image, ImageOps +import io +class ImageProcessor: + """이미지 처리 클래스""" + def __init__(self): + """이미지 프로세서 초기화""" + self.supported_formats = {'JPEG', 'PNG', 'WEBP', 'GIF'} + self.max_size = (2048, 2048) # 최대 크기 + self.thumbnail_size = (400, 400) # 썸네일 크기 + def get_image_info(self, image_path: str) -> Dict[str, Any]: + """ + 이미지 기본 정보 추출 + Args: + image_path: 이미지 파일 경로 + Returns: + 이미지 정보 딕셔너리 + """ + try: + with Image.open(image_path) as image: + info = { + 'filename': os.path.basename(image_path), + 'format': image.format, + 'mode': image.mode, + 'size': image.size, + 'width': image.width, + 'height': image.height, + 'file_size': os.path.getsize(image_path), + 'aspect_ratio': round(image.width / image.height, 2) if image.height > 0 else 0 + } + # 이미지 특성 분석 + info['is_landscape'] = image.width > image.height + info['is_portrait'] = image.height > image.width + info['is_square'] = abs(image.width - image.height) < 50 + return info + except Exception as e: + return { + 'filename': os.path.basename(image_path), + 'error': str(e) + } + def resize_image(self, image_path: str, target_size: Tuple[int, int], + maintain_aspect: bool = True) -> Image.Image: + """ + 이미지 크기 조정 + Args: + image_path: 원본 이미지 경로 + target_size: 목표 크기 (width, height) + maintain_aspect: 종횡비 유지 여부 + Returns: + 리사이즈된 PIL 이미지 + """ + try: + with Image.open(image_path) as image: + if maintain_aspect: + # 종횡비 유지하며 리사이즈 + image.thumbnail(target_size, Image.Resampling.LANCZOS) + return image.copy() + else: + # 강제 리사이즈 + return image.resize(target_size, Image.Resampling.LANCZOS) + except Exception as e: + raise Exception(f"이미지 리사이즈 실패: {str(e)}") + def optimize_image(self, image_path: str, quality: int = 85) -> bytes: + """ + 이미지 최적화 (파일 크기 줄이기) + Args: + image_path: 원본 이미지 경로 + quality: JPEG 품질 (1-100) + Returns: + 최적화된 이미지 바이트 + """ + try: + with Image.open(image_path) as image: + # RGBA를 RGB로 변환 (JPEG 저장을 위해) + if image.mode == 'RGBA': + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + image = background + # 크기가 너무 크면 줄이기 + if image.width > self.max_size[0] or image.height > self.max_size[1]: + image.thumbnail(self.max_size, Image.Resampling.LANCZOS) + # 바이트 스트림으로 저장 + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG', quality=quality, optimize=True) + return img_buffer.getvalue() + except Exception as e: + raise Exception(f"이미지 최적화 실패: {str(e)}") + def create_thumbnail(self, image_path: str, size: Tuple[int, int] = None) -> Image.Image: + """ + 썸네일 생성 + Args: + image_path: 원본 이미지 경로 + size: 썸네일 크기 (기본값: self.thumbnail_size) + Returns: + 썸네일 PIL 이미지 + """ + if size is None: + size = self.thumbnail_size + try: + with Image.open(image_path) as image: + # 정사각형 썸네일 생성 + thumbnail = ImageOps.fit(image, size, Image.Resampling.LANCZOS) + return thumbnail + except Exception as e: + raise Exception(f"썸네일 생성 실패: {str(e)}") + def analyze_colors(self, image_path: str, num_colors: int = 5) -> list: + """ + 이미지의 주요 색상 추출 + Args: + image_path: 이미지 파일 경로 + num_colors: 추출할 색상 개수 + Returns: + 주요 색상 리스트 [(R, G, B), ...] + """ + try: + with Image.open(image_path) as image: + # RGB로 변환 + if image.mode != 'RGB': + image = image.convert('RGB') + # 이미지 크기 줄여서 처리 속도 향상 + image.thumbnail((150, 150)) + # 색상 히스토그램 생성 + colors = image.getcolors(maxcolors=256*256*256) + if colors: + # 빈도순으로 정렬 + colors.sort(key=lambda x: x[0], reverse=True) + # 상위 색상들 반환 + dominant_colors = [] + for count, color in colors[:num_colors]: + dominant_colors.append(color) + return dominant_colors + return [(128, 128, 128)] # 기본 회색 + except Exception as e: + print(f"색상 분석 실패: {e}") + return [(128, 128, 128)] # 기본 회색 + def is_food_image(self, image_path: str) -> bool: + """ + 음식 이미지 여부 간단 판별 + (실제로는 AI 모델이 필요하지만, 여기서는 기본적인 휴리스틱 사용) + Args: + image_path: 이미지 파일 경로 + Returns: + 음식 이미지 여부 + """ + try: + # 파일명에서 키워드 확인 + filename = os.path.basename(image_path).lower() + food_keywords = ['food', 'meal', 'dish', 'menu', '음식', '메뉴', '요리'] + for keyword in food_keywords: + if keyword in filename: + return True + # 색상 분석으로 간단 판별 (음식은 따뜻한 색조가 많음) + colors = self.analyze_colors(image_path, 3) + warm_color_count = 0 + for r, g, b in colors: + # 따뜻한 색상 (빨강, 노랑, 주황 계열) 확인 + if r > 150 or (r > g and r > b): + warm_color_count += 1 + return warm_color_count >= 2 + except: + return False \ No newline at end of file diff --git a/.gitignore b/smarketing-java/.gitignore similarity index 100% rename from .gitignore rename to smarketing-java/.gitignore diff --git a/smarketing-java/ai-recommend/build.gradle b/smarketing-java/ai-recommend/build.gradle new file mode 100644 index 0000000..771a2fc --- /dev/null +++ b/smarketing-java/ai-recommend/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java new file mode 100644 index 0000000..c331ea3 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.recommend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication(scanBasePackages = { + "com.won.smarketing.recommend", + "com.won.smarketing.common" +}) +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence") +@EnableCaching +public class AIRecommendServiceApplication { + public static void main(String[] args) { + SpringApplication.run(AIRecommendServiceApplication.class, args); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java new file mode 100644 index 0000000..f54dc92 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -0,0 +1,101 @@ +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.repository.MarketingTipRepository; +import com.won.smarketing.recommend.domain.service.StoreDataProvider; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; +import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 마케팅 팁 서비스 구현체 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class MarketingTipService implements MarketingTipUseCase { + + private final MarketingTipRepository marketingTipRepository; + private final StoreDataProvider storeDataProvider; + private final AiTipGenerator aiTipGenerator; + + @Override + public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) { + log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId()); + + try { + // 1. 매장 정보 조회 + StoreData storeData = storeDataProvider.getStoreData(request.getStoreId()); + log.debug("매장 정보 조회 완료: {}", storeData.getStoreName()); + + // 2. Python AI 서비스로 팁 생성 (매장 정보 + 추가 요청사항 전달) + String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement()); + log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); + + // 3. 도메인 객체 생성 및 저장 + MarketingTip marketingTip = MarketingTip.builder() + .storeId(request.getStoreId()) + .tipContent(aiGeneratedTip) + .storeData(storeData) + .build(); + + MarketingTip savedTip = marketingTipRepository.save(marketingTip); + log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue()); + + return convertToResponse(savedTip); + + } catch (Exception e) { + log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + @Transactional(readOnly = true) + @Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize") + public Page getMarketingTipHistory(Long storeId, Pageable pageable) { + log.info("마케팅 팁 이력 조회: storeId={}", storeId); + + Page tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable); + + return tips.map(this::convertToResponse); + } + + @Override + @Transactional(readOnly = true) + public MarketingTipResponse getMarketingTip(Long tipId) { + log.info("마케팅 팁 상세 조회: tipId={}", tipId); + + MarketingTip marketingTip = marketingTipRepository.findById(tipId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR)); + + return convertToResponse(marketingTip); + } + + private MarketingTipResponse convertToResponse(MarketingTip marketingTip) { + return MarketingTipResponse.builder() + .tipId(marketingTip.getId().getValue()) + .storeId(marketingTip.getStoreId()) + .storeName(marketingTip.getStoreData().getStoreName()) + .tipContent(marketingTip.getTipContent()) + .storeInfo(MarketingTipResponse.StoreInfo.builder() + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .location(marketingTip.getStoreData().getLocation()) + .build()) + .createdAt(marketingTip.getCreatedAt()) + .build(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java new file mode 100644 index 0000000..48bd991 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -0,0 +1,27 @@ +package com.won.smarketing.recommend.application.usecase; + +import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest; +import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +/** + * 마케팅 팁 유즈케이스 인터페이스 + */ +public interface MarketingTipUseCase { + + /** + * AI 마케팅 팁 생성 + */ + MarketingTipResponse generateMarketingTips(MarketingTipRequest request); + + /** + * 마케팅 팁 이력 조회 + */ + Page getMarketingTipHistory(Long storeId, Pageable pageable); + + /** + * 마케팅 팁 상세 조회 + */ + MarketingTipResponse getMarketingTip(Long tipId); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java new file mode 100644 index 0000000..8dec201 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Configuration; + +/** + * 캐시 설정 + */ +@Configuration +@EnableCaching +public class CacheConfig { + // 기본 Simple 캐시 사용 +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java new file mode 100644 index 0000000..de705f5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * JPA 설정 + */ +@Configuration +@EnableJpaRepositories +public class JpaConfig { +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java new file mode 100644 index 0000000..53578a1 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java @@ -0,0 +1,29 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import io.netty.channel.ChannelOption; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +/** + * WebClient 설정 (간소화된 버전) + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) + .responseTimeout(Duration.ofMillis(5000)); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java new file mode 100644 index 0000000..302a79f --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -0,0 +1,33 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 마케팅 팁 도메인 모델 (날씨 정보 제거) + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MarketingTip { + + private TipId id; + private Long storeId; + private String tipContent; + private StoreData storeData; + private LocalDateTime createdAt; + + public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) { + return MarketingTip.builder() + .storeId(storeId) + .tipContent(tipContent) + .storeData(storeData) + .createdAt(LocalDateTime.now()) + .build(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java new file mode 100644 index 0000000..87c395d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -0,0 +1,19 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 매장 데이터 값 객체 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoreData { + private String storeName; + private String businessType; + private String location; +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java new file mode 100644 index 0000000..47808cb --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -0,0 +1,21 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 팁 ID 값 객체 + */ +@Getter +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class TipId { + private Long value; + + public static TipId of(Long value) { + return new TipId(value); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java new file mode 100644 index 0000000..ce0be77 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -0,0 +1,19 @@ +package com.won.smarketing.recommend.domain.repository; + +import com.won.smarketing.recommend.domain.model.MarketingTip; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스) + */ +public interface MarketingTipRepository { + + MarketingTip save(MarketingTip marketingTip); + + Optional findById(Long tipId); + + Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java new file mode 100644 index 0000000..19547c0 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java @@ -0,0 +1,18 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreData; + +/** + * AI 팁 생성 도메인 서비스 인터페이스 (단순화) + */ +public interface AiTipGenerator { + + /** + * Python AI 서비스를 통한 마케팅 팁 생성 + * + * @param storeData 매장 정보 + * @param additionalRequirement 추가 요청사항 + * @return AI가 생성한 마케팅 팁 + */ + String generateTip(StoreData storeData, String additionalRequirement); +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java similarity index 57% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java index bb36bc3..1cea568 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -4,15 +4,8 @@ import com.won.smarketing.recommend.domain.model.StoreData; /** * 매장 데이터 제공 도메인 서비스 인터페이스 - * 외부 매장 서비스로부터 매장 정보 조회 기능 정의 */ public interface StoreDataProvider { - /** - * 매장 ID로 매장 데이터 조회 - * - * @param storeId 매장 ID - * @return 매장 데이터 - */ StoreData getStoreData(Long storeId); } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java new file mode 100644 index 0000000..4356fa9 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -0,0 +1,138 @@ +package com.won.smarketing.recommend.infrastructure.external; + +import com.won.smarketing.recommend.domain.model.StoreData; +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, String additionalRequirement) { + try { + log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName()); + + // Python AI 서비스 사용 가능 여부 확인 + if (isPythonServiceAvailable()) { + return callPythonAiService(storeData, additionalRequirement); + } else { + log.warn("Python AI 서비스 사용 불가, Fallback 처리"); + return createFallbackTip(storeData, additionalRequirement); + } + + } catch (Exception e) { + log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); + return createFallbackTip(storeData, additionalRequirement); + } + } + + private boolean isPythonServiceAvailable() { + return !pythonAiServiceApiKey.equals("dummy-key"); + } + + private String callPythonAiService(StoreData storeData, String additionalRequirement) { + try { + // Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달) + Map requestData = Map.of( + "store_name", storeData.getStoreName(), + "business_type", storeData.getBusinessType(), + "location", storeData.getLocation(), + "additional_requirement", additionalRequirement != null ? additionalRequirement : "" + ); + + log.debug("Python AI 서비스 요청 데이터: {}", requestData); + + PythonAiResponse response = webClient + .post() + .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") + .header("Authorization", "Bearer " + pythonAiServiceApiKey) + .header("Content-Type", "application/json") + .bodyValue(requestData) + .retrieve() + .bodyToMono(PythonAiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { + log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length()); + return response.getTip(); + } + } catch (Exception e) { + log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); + } + + return createFallbackTip(storeData, additionalRequirement); + } + + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreData storeData, String additionalRequirement) { + String businessType = storeData.getBusinessType(); + String storeName = storeData.getStoreName(); + String location = storeData.getLocation(); + + // 추가 요청사항이 있는 경우 우선 반영 + if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) { + return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!", + storeName, additionalRequirement); + } + + // 업종별 기본 팁 생성 + if (businessType.contains("카페")) { + return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName); + } else if (businessType.contains("음식점") || businessType.contains("식당")) { + return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName); + } else if (businessType.contains("베이커리") || businessType.contains("빵집")) { + return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName); + } else if (businessType.contains("치킨") || businessType.contains("튀김")) { + return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName); + } + + // 지역별 팁 + if (location.contains("강남") || location.contains("서초")) { + return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName); + } else if (location.contains("홍대") || location.contains("신촌")) { + return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName); + } + + // 기본 팁 + return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName); + } + + private static class PythonAiResponse { + private String tip; + private String status; + private String message; + + public String getTip() { return tip; } + public void setTip(String tip) { this.tip = tip; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java new file mode 100644 index 0000000..c35a9e7 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -0,0 +1,124 @@ +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.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음 +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; + +/** + * 매장 API 데이터 제공자 구현체 + */ +@Slf4j +@Service // 추가된 어노테이션 +@RequiredArgsConstructor +public class StoreApiDataProvider implements StoreDataProvider { + + private final WebClient webClient; + + @Value("${external.store-service.base-url}") + private String storeServiceBaseUrl; + + @Value("${external.store-service.timeout}") + private int timeout; + + @Override + @Cacheable(value = "storeData", key = "#storeId") + public StoreData getStoreData(Long storeId) { + try { + log.debug("매장 정보 조회 시도: storeId={}", storeId); + + // 외부 서비스 연결 시도, 실패 시 Mock 데이터 반환 + if (isStoreServiceAvailable()) { + return callStoreService(storeId); + } else { + log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId); + return createMockStoreData(storeId); + } + + } catch (Exception e) { + log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); + return createMockStoreData(storeId); + } + } + + private boolean isStoreServiceAvailable() { + return !storeServiceBaseUrl.equals("http://localhost:8082"); + } + + private StoreData callStoreService(Long storeId) { + try { + StoreApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/store/" + storeId) + .retrieve() + .bodyToMono(StoreApiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getData() != null) { + StoreApiResponse.StoreInfo storeInfo = response.getData(); + return StoreData.builder() + .storeName(storeInfo.getStoreName()) + .businessType(storeInfo.getBusinessType()) + .location(storeInfo.getAddress()) + .build(); + } + } catch (WebClientResponseException e) { + if (e.getStatusCode().value() == 404) { + throw new BusinessException(ErrorCode.STORE_NOT_FOUND); + } + log.error("매장 서비스 호출 실패: {}", e.getMessage()); + } + + return createMockStoreData(storeId); + } + + private StoreData createMockStoreData(Long storeId) { + return StoreData.builder() + .storeName("테스트 카페 " + storeId) + .businessType("카페") + .location("서울시 강남구") + .build(); + } + + private static class StoreApiResponse { + private int status; + private String message; + private StoreInfo data; + + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public StoreInfo getData() { return data; } + public void setData(StoreInfo data) { this.data = data; } + + static class StoreInfo { + private Long storeId; + private String storeName; + private String businessType; + private String address; + private String phoneNumber; + + public Long getStoreId() { return storeId; } + public void setStoreId(Long storeId) { this.storeId = storeId; } + public String getStoreName() { return storeName; } + public void setStoreName(String storeName) { this.storeName = storeName; } + public String getBusinessType() { return businessType; } + public void setBusinessType(String businessType) { this.businessType = businessType; } + public String getAddress() { return address; } + public void setAddress(String address) { this.address = address; } + public String getPhoneNumber() { return phoneNumber; } + public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; } + } + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java new file mode 100644 index 0000000..1bccd9f --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java @@ -0,0 +1,79 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + +import com.won.smarketing.recommend.domain.model.MarketingTip; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.TipId; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +/** + * 마케팅 팁 JPA 엔티티 (날씨 정보 제거) + */ +@Entity +@Table(name = "marketing_tips") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MarketingTipEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "tip_content", nullable = false, length = 2000) + private String tipContent; + + // 매장 정보만 저장 + @Column(name = "store_name", length = 200) + private String storeName; + + @Column(name = "business_type", length = 100) + private String businessType; + + @Column(name = "store_location", length = 500) + private String storeLocation; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + public static MarketingTipEntity fromDomain(MarketingTip marketingTip) { + return MarketingTipEntity.builder() + .id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null) + .storeId(marketingTip.getStoreId()) + .tipContent(marketingTip.getTipContent()) + .storeName(marketingTip.getStoreData().getStoreName()) + .businessType(marketingTip.getStoreData().getBusinessType()) + .storeLocation(marketingTip.getStoreData().getLocation()) + .createdAt(marketingTip.getCreatedAt()) + .build(); + } + + public MarketingTip toDomain() { + StoreData storeData = StoreData.builder() + .storeName(this.storeName) + .businessType(this.businessType) + .location(this.storeLocation) + .build(); + + return MarketingTip.builder() + .id(this.id != null ? TipId.of(this.id) : null) + .storeId(this.storeId) + .tipContent(this.tipContent) + .storeData(storeData) + .createdAt(this.createdAt) + .build(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java new file mode 100644 index 0000000..e2a9d0d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java @@ -0,0 +1,18 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +/** + * 마케팅 팁 JPA 레포지토리 + */ +@Repository +public interface MarketingTipJpaRepository extends JpaRepository { + + @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") + Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java new file mode 100644 index 0000000..6b8198f --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + +import com.won.smarketing.recommend.domain.model.MarketingTip; +import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 마케팅 팁 레포지토리 구현체 + */ +@Repository +@RequiredArgsConstructor +public class MarketingTipRepositoryImpl implements MarketingTipRepository { + + private final MarketingTipJpaRepository jpaRepository; + + @Override + public MarketingTip save(MarketingTip marketingTip) { + MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip); + MarketingTipEntity savedEntity = jpaRepository.save(entity); + return savedEntity.toDomain(); + } + + @Override + public Optional findById(Long tipId) { + return jpaRepository.findById(tipId) + .map(MarketingTipEntity::toDomain); + } + + @Override + public Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) { + return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable) + .map(MarketingTipEntity::toDomain); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java new file mode 100644 index 0000000..ad30482 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java @@ -0,0 +1,34 @@ +//package com.won.smarketing.recommend.presentation.controller; +// +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import java.time.LocalDateTime; +//import java.util.Map; +// +///** +// * 헬스체크 컨트롤러 +// */ +//@RestController +//public class HealthController { +// +// @GetMapping("/health") +// public Map health() { +// return Map.of( +// "status", "UP", +// "service", "ai-recommend-service", +// "timestamp", LocalDateTime.now(), +// "message", "AI 추천 서비스가 정상 동작 중입니다.", +// "features", Map.of( +// "store_integration", "매장 서비스 연동", +// "python_ai_integration", "Python AI 서비스 연동", +// "fallback_support", "Fallback 팁 생성 지원" +// ) +// ); +// } +//} +// } +// +// } catch (Exception e) { +// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); +// return createMockStoreData(storeId); \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java new file mode 100644 index 0000000..89912d3 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -0,0 +1,77 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * AI 마케팅 추천 컨트롤러 + */ +@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API") +@Slf4j +@RestController +@RequestMapping("/api/recommendations") +@RequiredArgsConstructor +public class RecommendationController { + + private final MarketingTipUseCase marketingTipUseCase; + + @Operation( + summary = "AI 마케팅 팁 생성", + description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다." + ) + @PostMapping("/marketing-tips") + public ResponseEntity> generateMarketingTips( + @Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) { + + log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId()); + + MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request); + + log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation( + summary = "마케팅 팁 이력 조회", + description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다." + ) + @GetMapping("/marketing-tips") + public ResponseEntity>> getMarketingTipHistory( + @Parameter(description = "매장 ID") @RequestParam Long storeId, + Pageable pageable) { + + log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber()); + + Page response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + @Operation( + summary = "마케팅 팁 상세 조회", + description = "특정 마케팅 팁의 상세 정보를 조회합니다." + ) + @GetMapping("/marketing-tips/{tipId}") + public ResponseEntity> getMarketingTip( + @Parameter(description = "팁 ID") @PathVariable Long tipId) { + + log.info("마케팅 팁 상세 조회: tipId={}", tipId); + + MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId); + + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java similarity index 70% rename from ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java rename to smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java index 0bf5ff8..5a0ceb5 100644 --- a/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipRequest.java @@ -1,24 +1,26 @@ package com.won.smarketing.recommend.presentation.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -/** - * AI 마케팅 팁 생성 요청 DTO - * 매장 정보를 기반으로 개인화된 마케팅 팁을 요청할 때 사용됩니다. - */ +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +@Schema(description = "마케팅 팁 생성 요청") @Data +@Builder @NoArgsConstructor @AllArgsConstructor -@Schema(description = "AI 마케팅 팁 생성 요청") public class MarketingTipRequest { - + + @Schema(description = "매장 ID", example = "1", required = true) @NotNull(message = "매장 ID는 필수입니다") @Positive(message = "매장 ID는 양수여야 합니다") - @Schema(description = "매장 ID", example = "1", required = true) private Long storeId; + + @Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요") + private String additionalRequirement; } diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java new file mode 100644 index 0000000..6c7ac7f --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -0,0 +1,50 @@ +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; + +@Schema(description = "마케팅 팁 응답") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MarketingTipResponse { + + @Schema(description = "팁 ID", example = "1") + private Long tipId; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "카페 봄날") + private String storeName; + + @Schema(description = "AI 생성 마케팅 팁 내용") + private String tipContent; + + @Schema(description = "매장 정보") + private StoreInfo storeInfo; + + @Schema(description = "생성 일시") + private LocalDateTime createdAt; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StoreInfo { + @Schema(description = "매장명", example = "카페 봄날") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "위치", example = "서울시 강남구") + private String location; + } +} diff --git a/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml similarity index 51% rename from ai-recommend/src/main/resources/application.yml rename to smarketing-java/ai-recommend/src/main/resources/application.yml index 6604bab..018f81b 100644 --- a/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: application: name: ai-recommend-service datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommenddb} + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB} username: ${POSTGRES_USER:postgres} password: ${POSTGRES_PASSWORD:postgres} jpa: @@ -18,18 +18,29 @@ spring: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} external: - claude-ai: - api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key} - base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com} - model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229} - max-tokens: ${CLAUDE_AI_MAX_TOKENS:2000} - weather-api: - api-key: ${WEATHER_API_KEY:your-weather-api-key} - base-url: ${WEATHER_API_BASE_URL:https://api.openweathermap.org/data/2.5} store-service: base-url: ${STORE_SERVICE_URL:http://localhost:8082} + timeout: ${STORE_SERVICE_TIMEOUT:5000} + python-ai-service: + base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:8090} + api-key: ${PYTHON_AI_API_KEY:dummy-key} + timeout: ${PYTHON_AI_TIMEOUT:30000} + +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always springdoc: swagger-ui: @@ -41,4 +52,8 @@ springdoc: logging: level: com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG} - \ No newline at end of file + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file diff --git a/build.gradle b/smarketing-java/build.gradle similarity index 62% rename from build.gradle rename to smarketing-java/build.gradle index 36a1ec0..35646cd 100644 --- a/build.gradle +++ b/smarketing-java/build.gradle @@ -1,7 +1,16 @@ plugins { - id 'org.springframework.boot' version '3.4.0' apply false - id 'io.spring.dependency-management' version '1.1.4' apply false id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' +} + +allprojects { + group = 'com.won.smarketing' + version = '1.0.0' + + repositories { + mavenCentral() + } } subprojects { @@ -9,45 +18,34 @@ subprojects { apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' - group = 'com.won.smarketing' - version = '0.0.1-SNAPSHOT' - sourceCompatibility = '21' - - repositories { - mavenCentral() + configurations { + compileOnly { + extendsFrom annotationProcessor + } } dependencies { - // Spring Boot Starters - implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - - // Database - runtimeOnly 'org.postgresql:postgresql' - - // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0' - - // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' - - // Lombok + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' - // Test + // PostgreSQL (운영용) + runtimeOnly 'org.postgresql:postgresql:42.7.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' } - test { + tasks.named('test') { useJUnitPlatform() } -} +} \ No newline at end of file diff --git a/smarketing-java/common/build.gradle b/smarketing-java/common/build.gradle new file mode 100644 index 0000000..b46abbb --- /dev/null +++ b/smarketing-java/common/build.gradle @@ -0,0 +1,23 @@ +bootJar { + enabled = false +} + +jar { + enabled = true + archiveClassifier = '' +} + +// 공통 의존성 재정의 (API 노출용) +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' +} \ No newline at end of file diff --git a/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java similarity index 63% rename from common/src/main/java/com/won/smarketing/common/config/RedisConfig.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java index b648673..a0bc038 100644 --- a/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -21,6 +22,12 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int redisPort; + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Value("${spring.data.redis.ssl:true}") + private boolean useSsl; + /** * Redis 연결 팩토리 설정 * @@ -28,7 +35,22 @@ public class RedisConfig { */ @Bean public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(redisHost, redisPort); + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(redisHost); + config.setPort(redisPort); + + // Azure Redis는 패스워드 인증 필수 + if (redisPassword != null && !redisPassword.isEmpty()) { + config.setPassword(redisPassword); + } + + LettuceConnectionFactory factory = new LettuceConnectionFactory(config); + + // Azure Redis는 SSL 사용 (6380 포트) + factory.setUseSsl(useSsl); + factory.setValidateConnection(true); + + return factory; } /** diff --git a/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java similarity index 60% rename from common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java index 834b3bc..5c61143 100644 --- a/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -20,7 +20,7 @@ import java.util.Arrays; /** * Spring Security 설정 클래스 - * 인증, 인가, CORS 등 보안 관련 설정 + * JWT 기반 인증 및 CORS 설정 */ @Configuration @EnableWebSecurity @@ -30,17 +30,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; /** - * 패스워드 인코더 Bean 설정 - * - * @return BCrypt 패스워드 인코더 - */ - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - - /** - * Security Filter Chain 설정 + * Spring Security 필터 체인 설정 * * @param http HttpSecurity 객체 * @return SecurityFilterChain @@ -49,43 +39,43 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/member/register", - "/api/member/check-duplicate", - "/api/member/validate-password", - "/api/auth/login", - "/swagger-ui/**", - "/swagger-ui.html", - "/api-docs/**", - "/actuator/**" - ).permitAll() - .anyRequest().authenticated() - ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", + "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", + "/swagger-resources/**", "/webjars/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + /** + * 패스워드 인코더 빈 등록 + * + * @return BCryptPasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + /** * CORS 설정 * - * @return CORS 설정 소스 + * @return CorsConfigurationSource */ @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns(Arrays.asList("*")); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token")); - configuration.setExposedHeaders(Arrays.asList("x-auth-token")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); - configuration.setMaxAge(3600L); - + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java new file mode 100644 index 0000000..fb21909 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java @@ -0,0 +1,43 @@ +package com.won.smarketing.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger OpenAPI 설정 클래스 + * API 문서화 및 JWT 인증 설정 + */ +@Configuration +public class SwaggerConfig { + + /** + * OpenAPI 설정 + * + * @return OpenAPI 객체 + */ + @Bean + public OpenAPI openAPI() { + String jwtSchemeName = "jwtAuth"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .info(new Info() + .title("스마케팅 API") + .description("소상공인을 위한 AI 마케팅 서비스 API") + .version("1.0.0")) + .addSecurityItem(securityRequirement) + .components(components); + } +} diff --git a/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java new file mode 100644 index 0000000..ab77b3f --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java @@ -0,0 +1,68 @@ +package com.won.smarketing.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 페이징 응답 DTO + * 페이징된 데이터 응답에 사용되는 공통 형식 + * + * @param 응답 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "페이징 응답") +public class PageResponse { + + @Schema(description = "페이지 컨텐츠", example = "[...]") + private List content; + + @Schema(description = "페이지 번호 (0부터 시작)", example = "0") + private int pageNumber; + + @Schema(description = "페이지 크기", example = "20") + private int pageSize; + + @Schema(description = "전체 요소 수", example = "100") + private long totalElements; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + @Schema(description = "첫 번째 페이지 여부", example = "true") + private boolean first; + + @Schema(description = "마지막 페이지 여부", example = "false") + private boolean last; + + /** + * 성공적인 페이징 응답 생성 + * + * @param content 페이지 컨텐츠 + * @param pageNumber 페이지 번호 + * @param pageSize 페이지 크기 + * @param totalElements 전체 요소 수 + * @param 데이터 타입 + * @return 페이징 응답 + */ + public static PageResponse of(List content, int pageNumber, int pageSize, long totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / pageSize); + + return PageResponse.builder() + .content(content) + .pageNumber(pageNumber) + .pageSize(pageSize) + .totalElements(totalElements) + .totalPages(totalPages) + .first(pageNumber == 0) + .last(pageNumber >= totalPages - 1) + .build(); + } +} diff --git a/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/exception/BusinessException.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java diff --git a/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java similarity index 100% rename from common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d2da2b8 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,79 @@ +package com.won.smarketing.common.exception; + +import com.won.smarketing.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +/** + * 전역 예외 처리기 + * 애플리케이션 전반의 예외를 통일된 형식으로 처리 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 비즈니스 예외 처리 + * + * @param ex 비즈니스 예외 + * @return 오류 응답 + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + log.warn("Business exception occurred: {}", ex.getMessage()); + + return ResponseEntity + .status(ex.getErrorCode().getHttpStatus()) + .body(ApiResponse.error( + ex.getErrorCode().getHttpStatus().value(), + ex.getMessage() + )); + } + + /** + * 입력값 검증 예외 처리 + * + * @param ex 입력값 검증 예외 + * @return 오류 응답 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationException( + MethodArgumentNotValidException ex) { + log.warn("Validation exception occurred: {}", ex.getMessage()); + + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + return ResponseEntity.badRequest() + .body(ApiResponse.>builder() + .status(400) + .message("입력값 검증에 실패했습니다.") + .data(errors) + .build()); + } + + /** + * 일반적인 예외 처리 + * + * @param ex 예외 + * @return 오류 응답 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception ex) { + log.error("Unexpected exception occurred", ex); + + return ResponseEntity.internalServerError() + .body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다.")); + } +} diff --git a/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java similarity index 56% rename from common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java rename to smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java index 26aee46..16381bd 100644 --- a/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java @@ -7,8 +7,8 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -18,7 +18,7 @@ import java.util.Collections; /** * JWT 인증 필터 - * 요청 헤더에서 JWT 토큰을 추출하여 인증 처리 + * HTTP 요청에서 JWT 토큰을 추출하고 인증 처리 */ @Slf4j @Component @@ -30,44 +30,53 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; /** - * JWT 토큰 인증 처리 + * JWT 토큰 기반 인증 필터링 * * @param request HTTP 요청 * @param response HTTP 응답 * @param filterChain 필터 체인 * @throws ServletException 서블릿 예외 - * @throws IOException I/O 예외 + * @throws IOException IO 예외 */ @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - // 요청 헤더에서 JWT 토큰 추출 - String token = resolveToken(request); - - // 토큰이 있고 유효한 경우 인증 정보 설정 - if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { - String userId = jwtTokenProvider.getUserIdFromToken(token); - Authentication authentication = new UsernamePasswordAuthenticationToken( - userId, null, Collections.emptyList()); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId); + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + String userId = jwtTokenProvider.getUserIdFromToken(jwt); + + // 사용자 인증 정보 설정 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("User '{}' authenticated successfully", userId); + } + } catch (Exception ex) { + log.error("Could not set user authentication in security context", ex); } filterChain.doFilter(request, response); } /** - * 요청 헤더에서 JWT 토큰 추출 + * HTTP 요청에서 JWT 토큰 추출 * * @param request HTTP 요청 - * @return JWT 토큰 (Bearer 접두사 제거) + * @return JWT 토큰 (Bearer 접두사 제거된) */ - private String resolveToken(HttpServletRequest request) { + private String getJwtFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { return bearerToken.substring(BEARER_PREFIX.length()); } + return null; } } diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..d88bc8e --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java @@ -0,0 +1,126 @@ +package com.won.smarketing.common.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * JWT 토큰 생성 및 검증을 담당하는 클래스 + * 액세스 토큰과 리프레시 토큰의 생성, 검증, 파싱 기능 제공 + */ +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + /** + * -- GETTER -- + * 액세스 토큰 유효시간 반환 + * + * @return 액세스 토큰 유효시간 (밀리초) + */ + @Getter + private final long accessTokenValidityTime; + private final long refreshTokenValidityTime; + + /** + * JWT 토큰 프로바이더 생성자 + * + * @param secret JWT 서명에 사용할 비밀키 + * @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초) + * @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초) + */ + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-validity}") long accessTokenValidityTime, + @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenValidityTime = accessTokenValidityTime; + this.refreshTokenValidityTime = refreshTokenValidityTime; + } + + /** + * 액세스 토큰 생성 + * + * @param userId 사용자 ID + * @return 생성된 액세스 토큰 + */ + public String generateAccessToken(String userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + accessTokenValidityTime); + + return Jwts.builder() + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + /** + * 리프레시 토큰 생성 + * + * @param userId 사용자 ID + * @return 생성된 리프레시 토큰 + */ + public String generateRefreshToken(String userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime); + + return Jwts.builder() + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + /** + * 토큰에서 사용자 ID 추출 + * + * @param token JWT 토큰 + * @return 사용자 ID + */ + public String getUserIdFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.getSubject(); + } + + /** + * 토큰 유효성 검증 + * + * @param token 검증할 토큰 + * @return 유효성 여부 + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (SecurityException ex) { + log.error("Invalid JWT signature: {}", ex.getMessage()); + } catch (MalformedJwtException ex) { + log.error("Invalid JWT token: {}", ex.getMessage()); + } catch (ExpiredJwtException ex) { + log.error("Expired JWT token: {}", ex.getMessage()); + } catch (UnsupportedJwtException ex) { + log.error("Unsupported JWT token: {}", ex.getMessage()); + } catch (IllegalArgumentException ex) { + log.error("JWT claims string is empty: {}", ex.getMessage()); + } + return false; + } + +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/smarketing-java/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from gradle/wrapper/gradle-wrapper.jar rename to smarketing-java/gradle/wrapper/gradle-wrapper.jar diff --git a/gradle/wrapper/gradle-wrapper.properties b/smarketing-java/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from gradle/wrapper/gradle-wrapper.properties rename to smarketing-java/gradle/wrapper/gradle-wrapper.properties diff --git a/gradlew b/smarketing-java/gradlew similarity index 100% rename from gradlew rename to smarketing-java/gradlew diff --git a/gradlew.bat b/smarketing-java/gradlew.bat similarity index 96% rename from gradlew.bat rename to smarketing-java/gradlew.bat index 9b42019..9d21a21 100644 --- a/gradlew.bat +++ b/smarketing-java/gradlew.bat @@ -1,94 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle new file mode 100644 index 0000000..188d7bd --- /dev/null +++ b/smarketing-java/marketing-content/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'org.postgresql:postgresql' +} \ No newline at end of file diff --git a/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java similarity index 60% rename from marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java index 08115e2..537a189 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -3,15 +3,24 @@ package com.won.smarketing.content; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; /** * 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스 * Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스 */ -@SpringBootApplication(scanBasePackages = {"com.won.smarketing.content", "com.won.smarketing.common"}) -@EntityScan(basePackages = {"com.won.smarketing.content.infrastructure.entity"}) -@EnableJpaRepositories(basePackages = {"com.won.smarketing.content.infrastructure.repository"}) +@SpringBootApplication(scanBasePackages = { + "com.won.smarketing.content", + "com.won.smarketing.common" +}) +@EnableJpaRepositories(basePackages = { + "com.won.smarketing.content.infrastructure.repository" +}) +@EntityScan(basePackages = { + "com.won.smarketing.content.infrastructure.entity" +}) +@EnableJpaAuditing public class MarketingContentServiceApplication { public static void main(String[] args) { diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java similarity index 82% rename from marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java index eb868eb..c196e58 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java @@ -3,11 +3,7 @@ package com.won.smarketing.content.application.service; import com.won.smarketing.common.exception.BusinessException; import com.won.smarketing.common.exception.ErrorCode; import com.won.smarketing.content.application.usecase.ContentQueryUseCase; -import com.won.smarketing.content.domain.model.Content; -import com.won.smarketing.content.domain.model.ContentId; -import com.won.smarketing.content.domain.model.ContentStatus; -import com.won.smarketing.content.domain.model.ContentType; -import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.*; import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.presentation.dto.*; import lombok.RequiredArgsConstructor; @@ -44,18 +40,18 @@ public class ContentQueryService implements ContentQueryUseCase { // 제목과 기간 업데이트 content.updateTitle(request.getTitle()); - content.updatePeriod(request.getStartDate(), request.getEndDate()); + content.updatePeriod(request.getPromotionStartDate(), request.getPromotionEndDate()); Content updatedContent = contentRepository.save(content); return ContentUpdateResponse.builder() - .contentId(updatedContent.getId().getValue()) - .contentType(updatedContent.getContentType().name()) - .platform(updatedContent.getPlatform().name()) + .contentId(updatedContent.getId()) + //.contentType(updatedContent.getContentType().name()) + //.platform(updatedContent.getPlatform().name()) .title(updatedContent.getTitle()) .content(updatedContent.getContent()) - .hashtags(updatedContent.getHashtags()) - .images(updatedContent.getImages()) + //.hashtags(updatedContent.getHashtags()) + //.images(updatedContent.getImages()) .status(updatedContent.getStatus().name()) .updatedAt(updatedContent.getUpdatedAt()) .build(); @@ -109,7 +105,7 @@ public class ContentQueryService implements ContentQueryUseCase { .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); return ContentDetailResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) @@ -144,7 +140,7 @@ public class ContentQueryService implements ContentQueryUseCase { */ private ContentResponse toContentResponse(Content content) { return ContentResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) @@ -165,13 +161,13 @@ public class ContentQueryService implements ContentQueryUseCase { */ private OngoingContentResponse toOngoingContentResponse(Content content) { return OngoingContentResponse.builder() - .contentId(content.getId().getValue()) + .contentId(content.getId()) .contentType(content.getContentType().name()) .platform(content.getPlatform().name()) .title(content.getTitle()) .status(content.getStatus().name()) - .createdAt(content.getCreatedAt()) - .viewCount(0) // TODO: 실제 조회 수 구현 필요 + .promotionStartDate(content.getPromotionStartDate()) + //.viewCount(0) // TODO: 실제 조회 수 구현 필요 .build(); } @@ -181,20 +177,15 @@ public class ContentQueryService implements ContentQueryUseCase { * @param conditions CreationConditions 도메인 객체 * @return CreationConditionsDto */ - private CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { + private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { if (conditions == null) { return null; } - return CreationConditionsDto.builder() - .category(conditions.getCategory()) - .requirement(conditions.getRequirement()) + return ContentDetailResponse.CreationConditionsDto.builder() .toneAndManner(conditions.getToneAndManner()) .emotionIntensity(conditions.getEmotionIntensity()) .eventName(conditions.getEventName()) - .startDate(conditions.getStartDate()) - .endDate(conditions.getEndDate()) - .photoStyle(conditions.getPhotoStyle()) .build(); } } diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java similarity index 97% rename from marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java index c444d75..4db4d8a 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java @@ -61,10 +61,10 @@ public class PosterContentService implements PosterContentUseCase { .contentId(null) // 임시 생성이므로 ID 없음 .contentType(ContentType.POSTER.name()) .title(request.getTitle()) - .image(generatedPoster) + .posterImage(generatedPoster) .posterSizes(posterSizes) .status(ContentStatus.DRAFT.name()) - .createdAt(LocalDateTime.now()) + //.createdAt(LocalDateTime.now()) .build(); } diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java similarity index 96% rename from marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java index 508fbe5..dd8e603 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -33,7 +33,7 @@ public class SnsContentService implements SnsContentUseCase { /** * SNS 콘텐츠 생성 - * + * * @param request SNS 콘텐츠 생성 요청 * @return 생성된 SNS 콘텐츠 정보 */ @@ -42,7 +42,7 @@ public class SnsContentService implements SnsContentUseCase { public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { // AI를 사용하여 SNS 콘텐츠 생성 String generatedContent = aiContentGenerator.generateSnsContent(request); - + // 플랫폼에 맞는 해시태그 생성 Platform platform = Platform.fromString(request.getPlatform()); List hashtags = aiContentGenerator.generateHashtags(generatedContent, platform); @@ -60,7 +60,7 @@ public class SnsContentService implements SnsContentUseCase { // 임시 콘텐츠 생성 (저장하지 않음) Content content = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(platform) .title(request.getTitle()) .content(generatedContent) @@ -80,7 +80,7 @@ public class SnsContentService implements SnsContentUseCase { .title(content.getTitle()) .content(content.getContent()) .hashtags(content.getHashtags()) - .images(content.getImages()) + .fixedImages(content.getImages()) .status(content.getStatus().name()) .createdAt(content.getCreatedAt()) .build(); @@ -88,7 +88,7 @@ public class SnsContentService implements SnsContentUseCase { /** * SNS 콘텐츠 저장 - * + * * @param request SNS 콘텐츠 저장 요청 */ @Override @@ -107,7 +107,7 @@ public class SnsContentService implements SnsContentUseCase { // 콘텐츠 엔티티 생성 및 저장 Content content = Content.builder() - .contentType(ContentType.SNS_POST) +// .contentType(ContentType.SNS_POST) .platform(Platform.fromString(request.getPlatform())) .title(request.getTitle()) .content(request.getContent()) diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java similarity index 70% rename from marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java index 973b234..6bf2960 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java package com.won.smarketing.content.application.usecase; import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; @@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; /** - * 포스터 콘텐츠 관련 Use Case 인터페이스 - * 홍보 포스터 생성 및 저장 기능 정의 + * 포스터 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 */ public interface PosterContentUseCase { - + /** * 포스터 콘텐츠 생성 - * * @param request 포스터 콘텐츠 생성 요청 - * @return 생성된 포스터 콘텐츠 정보 + * @return 포스터 콘텐츠 생성 응답 */ PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); - + /** * 포스터 콘텐츠 저장 - * * @param request 포스터 콘텐츠 저장 요청 */ void savePosterContent(PosterContentSaveRequest request); -} +} \ No newline at end of file diff --git a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java similarity index 70% rename from marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java index e62902d..d2c6751 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java package com.won.smarketing.content.application.usecase; import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; @@ -5,23 +6,21 @@ import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; /** - * SNS 콘텐츠 관련 Use Case 인터페이스 - * SNS 게시물 생성 및 저장 기능 정의 + * SNS 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 */ public interface SnsContentUseCase { - + /** * SNS 콘텐츠 생성 - * * @param request SNS 콘텐츠 생성 요청 - * @return 생성된 SNS 콘텐츠 정보 + * @return SNS 콘텐츠 생성 응답 */ SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); - + /** * SNS 콘텐츠 저장 - * * @param request SNS 콘텐츠 저장 요청 */ void saveSnsContent(SnsContentSaveRequest request); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java new file mode 100644 index 0000000..3931d19 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java @@ -0,0 +1,9 @@ +package com.won.smarketing.content.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan(basePackages = "com.won.smarketing.content") +public class ContentConfig { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java new file mode 100644 index 0000000..f9a77b8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java @@ -0,0 +1,26 @@ + + +// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java +package com.won.smarketing.content.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ObjectMapper 설정 클래스 + * + * @author smarketing-team + * @version 1.0 + */ +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java new file mode 100644 index 0000000..9a19b77 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -0,0 +1,163 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +package com.won.smarketing.content.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 콘텐츠 도메인 모델 + * + * Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티 + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer에서 별도의 JPA 엔티티로 매핑 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Content { + + // ==================== 기본키 및 식별자 ==================== + private Long id; + + // ==================== 콘텐츠 분류 ==================== + private ContentType contentType; + private Platform platform; + + // ==================== 콘텐츠 내용 ==================== + private String title; + private String content; + + // ==================== 멀티미디어 및 메타데이터 ==================== + @Builder.Default + private List hashtags = new ArrayList<>(); + + @Builder.Default + private List images = new ArrayList<>(); + + // ==================== 상태 관리 ==================== + private ContentStatus status; + + // ==================== 생성 조건 ==================== + private CreationConditions creationConditions; + + // ==================== 매장 정보 ==================== + private Long storeId; + + // ==================== 프로모션 기간 ==================== + private LocalDateTime promotionStartDate; + private LocalDateTime promotionEndDate; + + // ==================== 메타데이터 ==================== + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 제목 수정 + * @param newTitle 새로운 제목 + */ + public void updateTitle(String newTitle) { + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); + } + this.title = newTitle.trim(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 내용 수정 + * @param newContent 새로운 내용 + */ + public void updateContent(String newContent) { + this.content = newContent; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 프로모션 기간 설정 + * @param startDate 시작일 + * @param endDate 종료일 + */ + public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다."); + } + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 상태 변경 + * @param newStatus 새로운 상태 + */ + public void updateStatus(ContentStatus newStatus) { + if (newStatus == null) { + throw new IllegalArgumentException("상태는 필수입니다."); + } + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 해시태그 추가 + * @param hashtag 추가할 해시태그 + */ + public void addHashtag(String hashtag) { + if (hashtag != null && !hashtag.trim().isEmpty()) { + if (this.hashtags == null) { + this.hashtags = new ArrayList<>(); + } + this.hashtags.add(hashtag.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 이미지 추가 + * @param imageUrl 추가할 이미지 URL + */ + public void addImage(String imageUrl) { + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + if (this.images == null) { + this.images = new ArrayList<>(); + } + this.images.add(imageUrl.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 프로모션 진행 중 여부 확인 + * @return 현재 시간이 프로모션 기간 내에 있으면 true + */ + public boolean isPromotionActive() { + if (promotionStartDate == null || promotionEndDate == null) { + return false; + } + LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate); + } + + /** + * 콘텐츠 게시 가능 여부 확인 + * @return 필수 정보가 모두 입력되어 있으면 true + */ + public boolean canBePublished() { + return title != null && !title.trim().isEmpty() + && contentType != null + && platform != null + && storeId != null; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java new file mode 100644 index 0000000..2f07e2c --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -0,0 +1,51 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +package com.won.smarketing.content.domain.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 ID 값 객체 + * Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리 + */ +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode +public class ContentId { + + private final Long value; + + /** + * Long 값으로부터 ContentId 생성 + * @param value ID 값 + * @return ContentId 인스턴스 + */ + public static ContentId of(Long value) { + if (value == null || value <= 0) { + throw new IllegalArgumentException("ContentId는 양수여야 합니다."); + } + return new ContentId(value); + } + + /** + * 새로운 ContentId 생성 (ID가 없는 경우) + * @return null 값을 가진 ContentId + */ + public static ContentId newId() { + return new ContentId(null); + } + + /** + * ID 값 존재 여부 확인 + * @return ID가 null이 아니면 true + */ + public boolean hasValue() { + return value != null; + } + + @Override + public String toString() { + return "ContentId{" + "value=" + value + '}'; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java new file mode 100644 index 0000000..b235310 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java @@ -0,0 +1,40 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 상태 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum ContentStatus { + + DRAFT("임시저장"), + PUBLISHED("게시됨"), + SCHEDULED("예약됨"), + DELETED("삭제됨"), + PROCESSING("처리중"); + + private final String displayName; + + /** + * 문자열로부터 ContentStatus 변환 + * @param value 문자열 값 + * @return ContentStatus enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static ContentStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다."); + } + + try { + return ContentStatus.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java new file mode 100644 index 0000000..f70228b --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java @@ -0,0 +1,39 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 타입 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum ContentType { + + SNS("SNS 게시물"), + POSTER("홍보 포스터"), + VIDEO("동영상"), + BLOG("블로그 포스트"); + + private final String displayName; + + /** + * 문자열로부터 ContentType 변환 + * @param value 문자열 값 + * @return ContentType enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static ContentType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다."); + } + + try { + return ContentType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java new file mode 100644 index 0000000..d7a9543 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -0,0 +1,58 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +package com.won.smarketing.content.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 도메인 모델 + * Clean Architecture의 Domain Layer에 위치하는 값 객체 + * + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer의 JPA 엔티티는 별도로 관리 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreationConditions { + + private String id; + private String category; + private String requirement; + private String toneAndManner; + private String emotionIntensity; + private String eventName; + private LocalDate startDate; + private LocalDate endDate; + private String photoStyle; + private String promotionType; + + public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + } + + /** + * 이벤트 기간 유효성 검증 + * @return 시작일이 종료일보다 이전이거나 같으면 true + */ + public boolean isValidEventPeriod() { + if (startDate == null || endDate == null) { + return true; + } + return !startDate.isAfter(endDate); + } + + /** + * 이벤트 조건 유무 확인 + * @return 이벤트명이나 날짜가 설정되어 있으면 true + */ + public boolean hasEventInfo() { + return eventName != null && !eventName.trim().isEmpty() + || startDate != null + || endDate != null; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java new file mode 100644 index 0000000..66e266c --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java @@ -0,0 +1,41 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 플랫폼 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum Platform { + + INSTAGRAM("인스타그램"), + NAVER_BLOG("네이버 블로그"), + FACEBOOK("페이스북"), + KAKAO_STORY("카카오스토리"), + YOUTUBE("유튜브"), + GENERAL("일반"); + + private final String displayName; + + /** + * 문자열로부터 Platform 변환 + * @param value 문자열 값 + * @return Platform enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static Platform fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Platform 값은 null일 수 없습니다."); + } + + try { + return Platform.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java similarity index 79% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java index 194c7aa..a2bfc43 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -1,3 +1,4 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java package com.won.smarketing.content.domain.repository; import com.won.smarketing.content.domain.model.Content; @@ -9,30 +10,27 @@ import java.util.List; import java.util.Optional; /** - * 콘텐츠 저장소 인터페이스 - * 콘텐츠 도메인의 데이터 접근 추상화 + * 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Domain Layer에서 데이터 접근 정의 */ public interface ContentRepository { - + /** * 콘텐츠 저장 - * * @param content 저장할 콘텐츠 * @return 저장된 콘텐츠 */ Content save(Content content); - + /** - * 콘텐츠 ID로 조회 - * + * ID로 콘텐츠 조회 * @param id 콘텐츠 ID - * @return 콘텐츠 (Optional) + * @return 조회된 콘텐츠 */ Optional findById(ContentId id); - + /** * 필터 조건으로 콘텐츠 목록 조회 - * * @param contentType 콘텐츠 타입 * @param platform 플랫폼 * @param period 기간 @@ -40,19 +38,17 @@ public interface ContentRepository { * @return 콘텐츠 목록 */ List findByFilters(ContentType contentType, Platform platform, String period, String sortBy); - + /** * 진행 중인 콘텐츠 목록 조회 - * * @param period 기간 * @return 진행 중인 콘텐츠 목록 */ List findOngoingContents(String period); - + /** - * 콘텐츠 삭제 - * + * ID로 콘텐츠 삭제 * @param id 삭제할 콘텐츠 ID */ void deleteById(ContentId id); -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..d3a6e42 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java @@ -0,0 +1,38 @@ +package com.won.smarketing.content.domain.repository; +import com.won.smarketing.content.infrastructure.entity.ContentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA ContentRepository + * JPA 기반 콘텐츠 데이터 접근 + */ +@Repository +public interface SpringDataContentRepository extends JpaRepository { + + /** + * 매장별 콘텐츠 조회 + * + * @param storeId 매장 ID + * @return 콘텐츠 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입별 조회 + * + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼별 조회 + * + * @param platform 플랫폼 + * @return 콘텐츠 목록 + */ + List findByPlatform(String platform); +} diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java diff --git a/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java similarity index 100% rename from marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java new file mode 100644 index 0000000..b549b05 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -0,0 +1,84 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 JPA 엔티티 + * Infrastructure Layer에서 데이터베이스 매핑을 담당 + */ +@Entity +@Table(name = "content_conditions") +@Getter +@Setter +public class ContentConditionsJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + private ContentJpaEntity content; + + @Column(name = "category", length = 100) + private String category; + + @Column(name = "requirement", columnDefinition = "TEXT") + private String requirement; + + @Column(name = "tone_and_manner", length = 100) + private String toneAndManner; + + @Column(name = "emotion_intensity", length = 50) + private String emotionIntensity; + + @Column(name = "event_name", length = 200) + private String eventName; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "photo_style", length = 100) + private String photoStyle; + + @Column(name = "promotion_type", length = 100) + private String promotionType; + + // 생성자 + public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + this.content = content; + this.category = category; + this.requirement = requirement; + this.toneAndManner = toneAndManner; + this.emotionIntensity = emotionIntensity; + this.eventName = eventName; + this.startDate = startDate; + this.endDate = endDate; + this.photoStyle = photoStyle; + this.promotionType = promotionType; + } + + public ContentConditionsJpaEntity() { + + } + + // 팩토리 메서드 + public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity, + eventName, startDate, endDate, photoStyle, promotionType); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java new file mode 100644 index 0000000..ba941d4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java @@ -0,0 +1,60 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +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; + +/** + * 콘텐츠 엔티티 + * 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Data +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ContentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "platform", nullable = false) + private String platform; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false) + private String status; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java new file mode 100644 index 0000000..bcc8499 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -0,0 +1,70 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 콘텐츠 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +public class ContentJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "content_type", nullable = false, length = 50) + private String contentType; + + @Column(name = "platform", length = 50) + private String platform; + + @Column(name = "title", length = 500) + private String title; + + @Column(name = "PromotionStartDate") + private LocalDateTime PromotionStartDate; + + @Column(name = "PromotionEndDate") + private LocalDateTime PromotionEndDate; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags", columnDefinition = "TEXT") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리 + @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private ContentConditionsJpaEntity conditions; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java new file mode 100644 index 0000000..b1d0e6d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java @@ -0,0 +1,32 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.List; + +/** + * AI 콘텐츠 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * @param title 제목 + * @param category 카테고리 + * @param platform 플랫폼 + * @param conditions 생성 조건 + * @return 생성된 콘텐츠 텍스트 + */ + String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions); + + /** + * 해시태그 생성 + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 생성된 해시태그 목록 + */ + List generateHashtags(String content, Platform platform); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java new file mode 100644 index 0000000..8bbe931 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java @@ -0,0 +1,29 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.Map; + +/** + * AI 포스터 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiPosterGenerator { + + /** + * 포스터 이미지 생성 + * @param title 제목 + * @param category 카테고리 + * @param conditions 생성 조건 + * @return 생성된 포스터 이미지 URL + */ + String generatePoster(String title, String category, CreationConditions conditions); + + /** + * 포스터 다양한 사이즈 생성 + * @param originalImage 원본 이미지 URL + * @return 사이즈별 이미지 URL 맵 + */ + Map generatePosterSizes(String originalImage); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java new file mode 100644 index 0000000..9d72f1f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -0,0 +1,95 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +// 수정: domain 패키지의 인터페이스를 import +import com.won.smarketing.content.domain.service.AiContentGenerator; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * Claude AI를 활용한 콘텐츠 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiContentGenerator implements AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + */ + @Override + public String generateSnsContent(SnsContentCreateRequest request) { + try { + String prompt = buildContentPrompt(request); + return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform())); + } catch (Exception e) { + log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); + return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); + } + } + + /** + * 플랫폼별 해시태그 생성 + */ + @Override + public List generateHashtags(String content, Platform platform) { + try { + return generateDummyHashtags(platform); + } catch (Exception e) { + log.error("해시태그 생성 실패: {}", e.getMessage(), e); + return generateFallbackHashtags(); + } + } + + private String buildContentPrompt(SnsContentCreateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + prompt.append("플랫폼: ").append(request.getPlatform()).append("\n"); + + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); + } + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + private String generateDummySnsContent(String title, Platform platform) { + String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" + + "저희 매장에서 특별한 경험을 만나보세요.\n" + + "고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n"; + + if (platform == Platform.INSTAGRAM) { + return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸"; + } else { + return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨"; + } + } + + private String generateFallbackContent(String title, Platform platform) { + return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!"; + } + + private List generateDummyHashtags(Platform platform) { + if (platform == Platform.INSTAGRAM) { + return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램"); + } else { + return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원"); + } + } + + private List generateFallbackHashtags() { + return Arrays.asList("#소상공인", "#마케팅", "#홍보"); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java new file mode 100644 index 0000000..7495966 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -0,0 +1,86 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Claude AI를 활용한 포스터 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiPosterGenerator implements AiPosterGenerator { + + /** + * 포스터 생성 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + @Override + public String generatePoster(PosterContentCreateRequest request) { + try { + // Claude AI API 호출 로직 + String prompt = buildPosterPrompt(request); + + // TODO: 실제 Claude AI API 호출 + // 현재는 더미 데이터 반환 + return generateDummyPosterUrl(request.getTitle()); + + } catch (Exception e) { + log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); + return generateFallbackPosterUrl(); + } + } + + /** + * 다양한 사이즈의 포스터 생성 + * + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 + */ + @Override + public Map generatePosterSizes(String baseImage) { + Map sizes = new HashMap<>(); + + // 다양한 사이즈 생성 (더미 구현) + sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); + sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); + sizes.put("facebook_post", baseImage + "_1200x630.jpg"); + sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); + + return sizes; + } + + private String buildPosterPrompt(PosterContentCreateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); + } + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + private String generateDummyPosterUrl(String title) { + return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; + } + + private String generateFallbackPosterUrl() { + return "https://dummy-ai-service.com/posters/fallback.jpg"; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java new file mode 100644 index 0000000..44fdb68 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -0,0 +1,213 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +package com.won.smarketing.content.infrastructure.mapper; + +import com.won.smarketing.content.domain.model.*; +import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 콘텐츠 도메인-엔티티 매퍼 + * Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당 + * + * @author smarketing-team + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentMapper { + + private final ObjectMapper objectMapper; + + /** + * 도메인 모델을 JPA 엔티티로 변환 + * + * @param content 도메인 콘텐츠 + * @return JPA 엔티티 + */ + public ContentJpaEntity toEntity(Content content) { + if (content == null) { + return null; + } + + ContentJpaEntity entity = new ContentJpaEntity(); + + // 기본 필드 매핑 + if (content.getId() != null) { + entity.setId(content.getId()); + } + entity.setStoreId(content.getStoreId()); + entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null); + entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null); + entity.setTitle(content.getTitle()); + entity.setContent(content.getContent()); + entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT"); + entity.setPromotionStartDate(content.getPromotionStartDate()); + entity.setPromotionEndDate(content.getPromotionEndDate()); + entity.setCreatedAt(content.getCreatedAt()); + entity.setUpdatedAt(content.getUpdatedAt()); + + // 컬렉션 필드를 JSON으로 변환 + entity.setHashtags(convertListToJson(content.getHashtags())); + entity.setImages(convertListToJson(content.getImages())); + + // 생성 조건 정보 매핑 + if (content.getCreationConditions() != null) { + ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions()); + conditionsEntity.setContent(entity); + entity.setConditions(conditionsEntity); + } + + return entity; + } + + /** + * JPA 엔티티를 도메인 모델로 변환 + * + * @param entity JPA 엔티티 + * @return 도메인 모델 + */ + public Content toDomain(ContentJpaEntity entity) { + if (entity == null) { + return null; + } + + return Content.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .contentType(parseContentType(entity.getContentType())) + .platform(parsePlatform(entity.getPlatform())) + .title(entity.getTitle()) + .content(entity.getContent()) + .hashtags(convertJsonToList(entity.getHashtags())) + .images(convertJsonToList(entity.getImages())) + .status(parseContentStatus(entity.getStatus())) + .promotionStartDate(entity.getPromotionStartDate()) + .promotionEndDate(entity.getPromotionEndDate()) + .creationConditions(mapToConditionsDomain(entity.getConditions())) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * CreationConditions 도메인을 JPA 엔티티로 변환 + */ + private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) { + ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity(); + entity.setCategory(conditions.getCategory()); + entity.setRequirement(conditions.getRequirement()); + entity.setToneAndManner(conditions.getToneAndManner()); + entity.setEmotionIntensity(conditions.getEmotionIntensity()); + entity.setEventName(conditions.getEventName()); + entity.setStartDate(conditions.getStartDate()); + entity.setEndDate(conditions.getEndDate()); + entity.setPhotoStyle(conditions.getPhotoStyle()); + entity.setPromotionType(conditions.getPromotionType()); + return entity; + } + + /** + * CreationConditions JPA 엔티티를 도메인으로 변환 + */ + private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) { + if (entity == null) { + return null; + } + + return CreationConditions.builder() + .category(entity.getCategory()) + .requirement(entity.getRequirement()) + .toneAndManner(entity.getToneAndManner()) + .emotionIntensity(entity.getEmotionIntensity()) + .eventName(entity.getEventName()) + .startDate(entity.getStartDate()) + .endDate(entity.getEndDate()) + .photoStyle(entity.getPhotoStyle()) + .promotionType(entity.getPromotionType()) + .build(); + } + + /** + * List를 JSON 문자열로 변환 + */ + private String convertListToJson(List list) { + if (list == null || list.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + log.warn("Failed to convert list to JSON: {}", e.getMessage()); + return null; + } + } + + /** + * JSON 문자열을 List로 변환 + */ + private List convertJsonToList(String json) { + if (json == null || json.trim().isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to convert JSON to list: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * 문자열을 ContentType 열거형으로 변환 + */ + private ContentType parseContentType(String contentType) { + if (contentType == null) { + return null; + } + try { + return ContentType.valueOf(contentType); + } catch (IllegalArgumentException e) { + log.warn("Unknown content type: {}", contentType); + return null; + } + } + + /** + * 문자열을 Platform 열거형으로 변환 + */ + private Platform parsePlatform(String platform) { + if (platform == null) { + return null; + } + try { + return Platform.valueOf(platform); + } catch (IllegalArgumentException e) { + log.warn("Unknown platform: {}", platform); + return null; + } + } + + /** + * 문자열을 ContentStatus 열거형으로 변환 + */ + private ContentStatus parseContentStatus(String status) { + if (status == null) { + return ContentStatus.DRAFT; + } + try { + return ContentStatus.valueOf(status); + } catch (IllegalArgumentException e) { + log.warn("Unknown content status: {}", status); + return ContentStatus.DRAFT; + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java new file mode 100644 index 0000000..f3f38ed --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -0,0 +1,147 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.won.smarketing.content.infrastructure.mapper.ContentMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * JPA를 활용한 콘텐츠 리포지토리 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용 + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class JpaContentRepository implements ContentRepository { + + private final JpaContentRepositoryInterface jpaRepository; + private final ContentMapper contentMapper; + + /** + * 콘텐츠 저장 + * @param content 저장할 도메인 콘텐츠 + * @return 저장된 도메인 콘텐츠 + */ + @Override + public Content save(Content content) { + log.debug("Saving content: {}", content.getTitle()); + + // 도메인 모델을 JPA 엔티티로 변환 + ContentJpaEntity entity = contentMapper.toEntity(content); + + // JPA로 저장 + ContentJpaEntity savedEntity = jpaRepository.save(entity); + + // JPA 엔티티를 도메인 모델로 변환하여 반환 + Content savedContent = contentMapper.toDomain(savedEntity); + + log.debug("Content saved with ID: {}", savedContent.getId()); + return savedContent; + } + + /** + * ID로 콘텐츠 조회 + * @param id 콘텐츠 ID + * @return 조회된 도메인 콘텐츠 + */ + @Override + public Optional findById(ContentId id) { + log.debug("Finding content by ID: {}", id.getValue()); + + return jpaRepository.findById(id.getValue()) + .map(contentMapper::toDomain); + } + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 (현재는 사용하지 않음) + * @param sortBy 정렬 기준 (현재는 사용하지 않음) + * @return 도메인 콘텐츠 목록 + */ + @Override + public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { + log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform); + + String contentTypeStr = contentType != null ? contentType.name() : null; + String platformStr = platform != null ? platform.name() : null; + + List entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * @param period 기간 (현재는 사용하지 않음) + * @return 진행 중인 도메인 콘텐츠 목록 + */ + @Override + public List findOngoingContents(String period) { + log.debug("Finding ongoing contents"); + + List entities = jpaRepository.findOngoingContents(); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID + */ + @Override + public void deleteById(ContentId id) { + log.debug("Deleting content by ID: {}", id.getValue()); + + jpaRepository.deleteById(id.getValue()); + + log.debug("Content deleted successfully"); + } + + /** + * 매장 ID로 콘텐츠 목록 조회 (추가 메서드) + * @param storeId 매장 ID + * @return 도메인 콘텐츠 목록 + */ + public List findByStoreId(Long storeId) { + log.debug("Finding contents by store ID: {}", storeId); + + List entities = jpaRepository.findByStoreId(storeId); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 타입으로 조회 (추가 메서드) + * @param contentType 콘텐츠 타입 + * @return 도메인 콘텐츠 목록 + */ + public List findByContentType(ContentType contentType) { + log.debug("Finding contents by type: {}", contentType); + + List entities = jpaRepository.findByContentType(contentType.name()); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java new file mode 100644 index 0000000..37c4e74 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -0,0 +1,87 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.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 java.util.List; + +/** + * Spring Data JPA 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근 + */ +public interface JpaContentRepositoryInterface extends JpaRepository { + + /** + * 매장 ID로 콘텐츠 목록 조회 + * @param storeId 매장 ID + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입으로 조회 + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼으로 조회 + * @param platform 플랫폼 + * @return 콘텐츠 엔티티 목록 + */ + List findByPlatform(String platform); + + /** + * 상태로 조회 + * @param status 상태 + * @return 콘텐츠 엔티티 목록 + */ + List findByStatus(String status); + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 (null 가능) + * @param platform 플랫폼 (null 가능) + * @param status 상태 (null 가능) + * @return 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "(:contentType IS NULL OR c.contentType = :contentType) AND " + + "(:platform IS NULL OR c.platform = :platform) AND " + + "(:status IS NULL OR c.status = :status) " + + "ORDER BY c.createdAt DESC") + List findByFilters(@Param("contentType") String contentType, + @Param("platform") String platform, + @Param("status") String status); + + /** + * 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠) + * @return 진행 중인 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "c.status IN ('PUBLISHED', 'SCHEDULED') " + + "ORDER BY c.createdAt DESC") + List findOngoingContents(); + + /** + * 매장 ID와 콘텐츠 타입으로 조회 + * @param storeId 매장 ID + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreIdAndContentType(Long storeId, String contentType); + + /** + * 최근 생성된 콘텐츠 조회 (limit 적용) + * @param storeId 매장 ID + * @return 최근 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " + + "ORDER BY c.createdAt DESC") + List findRecentContentsByStoreId(@Param("storeId") Long storeId); +} \ No newline at end of file diff --git a/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java similarity index 99% rename from marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java rename to smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java index e65842a..4feb6b7 100644 --- a/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; import java.util.List; /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java new file mode 100644 index 0000000..7cc6a52 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java @@ -0,0 +1,86 @@ +package com.won.smarketing.content.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; +import java.util.List; + +/** + * 콘텐츠 상세 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 상세 응답") +public class ContentDetailResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "생성 조건") + private CreationConditionsDto creationConditions; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; + + /** + * 생성 조건 내부 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class CreationConditionsDto { + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java new file mode 100644 index 0000000..8a35e35 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java @@ -0,0 +1,37 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 목록 조회 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 목록 조회 요청") +public class ContentListRequest { + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "조회 기간", example = "7days") + private String period; + + @Schema(description = "정렬 기준", example = "createdAt") + private String sortBy; + + @Schema(description = "정렬 방향", example = "DESC") + private String sortDirection; + + @Schema(description = "페이지 번호", example = "0") + private Integer page; + + @Schema(description = "페이지 크기", example = "20") + private Integer size; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java new file mode 100644 index 0000000..47060a0 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 재생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 재생성 요청") +public class ContentRegenerateRequest { + + @Schema(description = "원본 콘텐츠 ID", example = "1", required = true) + @NotNull(message = "원본 콘텐츠 ID는 필수입니다") + private Long originalContentId; + + @Schema(description = "수정된 톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "수정된 프로모션 유형", example = "신메뉴 알림") + private String promotionType; + + @Schema(description = "수정된 감정 강도", example = "열정적") + private String emotionIntensity; + + @Schema(description = "추가 요구사항", example = "더 감성적으로 작성해주세요") + private String additionalRequirements; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java new file mode 100644 index 0000000..964f4a2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java @@ -0,0 +1,364 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java +package com.won.smarketing.content.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; +import java.util.List; + +/** + * 콘텐츠 응답 DTO + * 콘텐츠 목록 조회 시 사용되는 기본 응답 DTO + * + * 이 클래스는 콘텐츠의 핵심 정보만을 포함하여 목록 조회 시 성능을 최적화합니다. + * 상세 정보가 필요한 경우 ContentDetailResponse를 사용합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 응답") +public class ContentResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST", + allowableValues = {"SNS_POST", "POSTER"}) + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== 콘텐츠 정보 ==================== + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용", example = "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!") + private String content; + + @Schema(description = "해시태그 목록", example = "[\"#맛집\", \"#신메뉴\", \"#추천\", \"#인스타그램\"]") + private List hashtags; + + @Schema(description = "이미지 URL 목록", + example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]") + private List images; + + // ==================== 상태 관리 ==================== + + @Schema(description = "상태", example = "PUBLISHED", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED", "ARCHIVED"}) + private String status; + + @Schema(description = "상태 표시명", example = "발행완료") + private String statusDisplay; + + // ==================== 홍보 기간 ==================== + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + + // ==================== 시간 정보 ==================== + + @Schema(description = "생성일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T14:20:00") + private LocalDateTime updatedAt; + + // ==================== 계산된 필드들 ==================== + + @Schema(description = "홍보 진행 상태", example = "ONGOING", + allowableValues = {"UPCOMING", "ONGOING", "COMPLETED"}) + private String promotionStatus; + + @Schema(description = "남은 홍보 일수", example = "5") + private Long remainingDays; + + @Schema(description = "홍보 진행률 (%)", example = "60.5") + private Double progressPercentage; + + @Schema(description = "콘텐츠 요약 (첫 50자)", example = "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...") + private String contentSummary; + + @Schema(description = "이미지 개수", example = "3") + private Integer imageCount; + + @Schema(description = "해시태그 개수", example = "8") + private Integer hashtagCount; + + @Schema(description = "조회수", example = "8") + private Integer viewCount; + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 요약 생성 + * 콘텐츠가 길 경우 첫 50자만 표시하고 "..." 추가 + * + * @param content 원본 콘텐츠 + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public static String createContentSummary(String content, int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 홍보 상태 계산 + * 현재 시간과 홍보 기간을 비교하여 상태 결정 + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 홍보 상태 + */ + public static String calculatePromotionStatus(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return "UNKNOWN"; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return "UPCOMING"; // 홍보 예정 + } else if (now.isAfter(endDate)) { + return "COMPLETED"; // 홍보 완료 + } else { + return "ONGOING"; // 홍보 진행중 + } + } + + /** + * 남은 일수 계산 + * 홍보 종료일까지 남은 일수 계산 + * + * @param endDate 홍보 종료일 + * @return 남은 일수 (음수면 0 반환) + */ + public static Long calculateRemainingDays(LocalDateTime endDate) { + if (endDate == null) { + return 0L; + } + + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(endDate)) { + return 0L; + } + + return java.time.Duration.between(now, endDate).toDays(); + } + + /** + * 진행률 계산 + * 홍보 기간 대비 진행률 계산 (0-100%) + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 진행률 (0-100%) + */ + public static Double calculateProgressPercentage(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return 0.0; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return 0.0; // 아직 시작 안함 + } else if (now.isAfter(endDate)) { + return 100.0; // 완료 + } + + long totalDuration = java.time.Duration.between(startDate, endDate).toHours(); + long elapsedDuration = java.time.Duration.between(startDate, now).toHours(); + + if (totalDuration == 0) { + return 100.0; + } + + return (double) elapsedDuration / totalDuration * 100.0; + } + + /** + * 상태 표시명 변환 + * 영문 상태를 한글로 변환 + * + * @param status 영문 상태 + * @return 한글 상태명 + */ + public static String getStatusDisplay(String status) { + if (status == null) { + return "알 수 없음"; + } + + switch (status) { + case "DRAFT": + return "임시저장"; + case "PUBLISHED": + return "발행완료"; + case "SCHEDULED": + return "예약발행"; + case "ARCHIVED": + return "보관됨"; + default: + return status; + } + } + + // ==================== Builder 확장 메서드 ==================== + + /** + * 도메인 엔티티에서 ContentResponse 생성 + * 계산된 필드들을 자동으로 설정 + * + * @param content 콘텐츠 도메인 엔티티 + * @return ContentResponse + */ + public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) { + ContentResponseBuilder builder = ContentResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .statusDisplay(getStatusDisplay(content.getStatus().name())) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .createdAt(content.getCreatedAt()) + .updatedAt(content.getUpdatedAt()); + + // 계산된 필드들 설정 + builder.contentSummary(createContentSummary(content.getContent(), 50)); + builder.imageCount(content.getImages() != null ? content.getImages().size() : 0); + builder.hashtagCount(content.getHashtags() != null ? content.getHashtags().size() : 0); + + // 홍보 관련 계산 필드들 + builder.promotionStatus(calculatePromotionStatus( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + builder.remainingDays(calculateRemainingDays(content.getPromotionEndDate())); + builder.progressPercentage(calculateProgressPercentage( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + + return builder.build(); + } + + // ==================== 유틸리티 메서드 ==================== + + /** + * 콘텐츠가 현재 활성 상태인지 확인 + * + * @return 홍보 기간 내이고 발행 상태면 true + */ + public boolean isActive() { + return "PUBLISHED".equals(status) && "ONGOING".equals(promotionStatus); + } + + /** + * 콘텐츠 수정 가능 여부 확인 + * + * @return 임시저장 상태이거나 예약발행 상태면 true + */ + public boolean isEditable() { + return "DRAFT".equals(status) || "SCHEDULED".equals(status); + } + + /** + * 이미지가 있는 콘텐츠인지 확인 + * + * @return 이미지가 1개 이상 있으면 true + */ + public boolean hasImages() { + return images != null && !images.isEmpty(); + } + + /** + * 해시태그가 있는 콘텐츠인지 확인 + * + * @return 해시태그가 1개 이상 있으면 true + */ + public boolean hasHashtags() { + return hashtags != null && !hashtags.isEmpty(); + } + + /** + * 디버깅용 toString (간소화된 정보만) + */ + @Override + public String toString() { + return "ContentResponse{" + + "contentId=" + contentId + + ", contentType='" + contentType + '\'' + + ", platform='" + platform + '\'' + + ", title='" + title + '\'' + + ", status='" + status + '\'' + + ", promotionStatus='" + promotionStatus + '\'' + + ", createdAt=" + createdAt + + '}'; + } +} + +/* +==================== 사용 예시 ==================== + +// 1. 도메인 엔티티에서 DTO 생성 +Content domainContent = contentRepository.findById(contentId); +ContentResponse response = ContentResponse.fromDomain(domainContent); + +// 2. 수동으로 빌더 사용 +ContentResponse response = ContentResponse.builder() + .contentId(1L) + .contentType("SNS_POST") + .platform("INSTAGRAM") + .title("맛있는 신메뉴") + .content("특별한 신메뉴가 출시되었습니다!") + .status("PUBLISHED") + .build(); + +// 3. 비즈니스 로직 활용 +boolean canEdit = response.isEditable(); +boolean isLive = response.isActive(); +String summary = response.getContentSummary(); + +==================== JSON 응답 예시 ==================== + +{ + "contentId": 1, + "contentType": "SNS_POST", + "platform": "INSTAGRAM", + "title": "맛있는 신메뉴를 소개합니다!", + "content": "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!", + "hashtags": ["#맛집", "#신메뉴", "#추천", "#인스타그램"], + "images": ["https://example.com/image1.jpg"], + "status": "PUBLISHED", + "statusDisplay": "발행완료", + "promotionStartDate": "2024-01-15T09:00:00", + "promotionEndDate": "2024-01-22T23:59:59", + "createdAt": "2024-01-15T10:30:00", + "updatedAt": "2024-01-15T14:20:00", + "promotionStatus": "ONGOING", + "remainingDays": 5, + "progressPercentage": 60.5, + "contentSummary": "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...", + "imageCount": 1, + "hashtagCount": 4 +} +*/ \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java new file mode 100644 index 0000000..fed7dfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 콘텐츠 통계 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 통계 응답") +public class ContentStatisticsResponse { + + @Schema(description = "총 콘텐츠 수", example = "150") + private Long totalContents; + + @Schema(description = "이번 달 생성된 콘텐츠 수", example = "25") + private Long thisMonthContents; + + @Schema(description = "발행된 콘텐츠 수", example = "120") + private Long publishedContents; + + @Schema(description = "임시저장된 콘텐츠 수", example = "30") + private Long draftContents; + + @Schema(description = "콘텐츠 타입별 통계") + private Map contentTypeStats; + + @Schema(description = "플랫폼별 통계") + private Map platformStats; + + @Schema(description = "월별 생성 통계 (최근 6개월)") + private Map monthlyStats; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java new file mode 100644 index 0000000..d550f0f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 수정 요청") +public class ContentUpdateRequest { + + @Schema(description = "제목", example = "수정된 제목") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java new file mode 100644 index 0000000..3296ee2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java @@ -0,0 +1,35 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 수정 응답") +public class ContentUpdateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "수정된 제목", example = "수정된 제목") + private String title; + + @Schema(description = "수정된 콘텐츠 내용") + private String content; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java new file mode 100644 index 0000000..403cdfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java @@ -0,0 +1,45 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 생성 조건") +public class CreationConditionsDto { + + @Schema(description = "카테고리", example = "음식") + private String category; + + @Schema(description = "생성 요구사항", example = "젊은 고객층을 타겟으로 한 재미있는 콘텐츠") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근하고 활발한") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "시작일") + private LocalDate startDate; + + @Schema(description = "종료일") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "모던하고 깔끔한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java new file mode 100644 index 0000000..047cb2d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java @@ -0,0 +1,47 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 진행 중인 콘텐츠 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "진행 중인 콘텐츠 응답") +public class OngoingContentResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "진행 중인 이벤트") + private String title; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "남은 일수", example = "5") + private Long remainingDays; + + @Schema(description = "진행률 (%)", example = "60.5") + private Double progressPercentage; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java new file mode 100644 index 0000000..3ea3a15 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java @@ -0,0 +1,79 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 포스터 콘텐츠 생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 생성 요청") +public class PosterContentCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "홍보 대상", example = "메뉴", required = true) + @NotBlank(message = "홍보 대상은 필수입니다") + private String targetAudience; + + @Schema(description = "홍보 시작일", required = true) + @NotNull(message = "홍보 시작일은 필수입니다") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", required = true) + @NotNull(message = "홍보 종료일은 필수입니다") + private LocalDateTime promotionEndDate; + + @Schema(description = "이벤트명 (이벤트 홍보시)", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "업로드된 이미지 URL 목록", required = true) + @NotNull(message = "이미지는 1개 이상 필수입니다") + @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + private List images; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java new file mode 100644 index 0000000..0c02b68 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java @@ -0,0 +1,49 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 포스터 콘텐츠 생성 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "포스터 콘텐츠 생성 응답") +public class PosterContentCreateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "생성된 포스터 제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "생성된 포스터 텍스트 내용") + private String content; + + @Schema(description = "생성된 포스터 타입") + private String contentType; + + @Schema(description = "포스터 이미지 URL") + private String posterImage; + + @Schema(description = "원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "생성 상태", example = "DRAFT") + private String status; + + @Schema(description = "포스터사이즈", example = "800x600") + private Map posterSizes; + +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java new file mode 100644 index 0000000..5335d11 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java @@ -0,0 +1,66 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** + * 포스터 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 저장 요청") +public class PosterContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "선택된 포스터 이미지 URL") + private List images; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java new file mode 100644 index 0000000..70235b5 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java @@ -0,0 +1,160 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** + * SNS 콘텐츠 생성 요청 DTO + * + * AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다. + * 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 요청") +public class SnsContentCreateRequest { + + // ==================== 기본 정보 ==================== + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "대상 플랫폼", + example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}, + required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "콘텐츠 제목", example = "1", required = true) + @NotNull(message = "콘텐츠 제목은 필수입니다") + private String title; + + // ==================== 콘텐츠 생성 조건 ==================== + + @Schema(description = "콘텐츠 카테고리", + example = "메뉴소개", + allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"}) + private String category; + + @Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용", + example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + @Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요") + private String requirement; + + @Schema(description = "톤앤매너", + example = "친근함", + allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) + private String toneAndManner; + + @Schema(description = "감정 강도", + example = "보통", + allowableValues = {"약함", "보통", "강함"}) + private String emotionIntensity; + + // ==================== 이벤트 정보 ==================== + + @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", + example = "신메뉴 출시 이벤트") + @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") + private String eventName; + + @Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)", + example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)", + example = "2024-01-31") + private LocalDate endDate; + + // ==================== 미디어 정보 ==================== + + @Schema(description = "업로드된 이미지 파일 경로 목록") + private List images; + + @Schema(description = "사진 스타일 선호도", + example = "밝고 화사한", + allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"}) + private String photoStyle; + + // ==================== 추가 옵션 ==================== + + @Schema(description = "해시태그 포함 여부", example = "true") + @Builder.Default + private Boolean includeHashtags = true; + + @Schema(description = "이모지 포함 여부", example = "true") + @Builder.Default + private Boolean includeEmojis = true; + + @Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true") + @Builder.Default + private Boolean includeCallToAction = true; + + @Schema(description = "매장 위치 정보 포함 여부", example = "false") + @Builder.Default + private Boolean includeLocation = false; + + // ==================== 플랫폼별 옵션 ==================== + + @Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false") + @Builder.Default + private Boolean forInstagramStory = false; + + @Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false") + @Builder.Default + private Boolean forNaverBlogPost = false; + + // ==================== AI 생성 옵션 ==================== + + @Schema(description = "대안 제목 생성 개수", example = "3") + @Builder.Default + private Integer alternativeTitleCount = 3; + + @Schema(description = "대안 해시태그 세트 생성 개수", example = "2") + @Builder.Default + private Integer alternativeHashtagSetCount = 2; + + @Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo") + private String preferredAiModel; + + // ==================== 검증 메서드 ==================== + + /** + * 이벤트 날짜 유효성 검증 + * 시작일이 종료일보다 이후인지 확인 + */ + public boolean isValidEventDates() { + if (startDate != null && endDate != null) { + return !startDate.isAfter(endDate); + } + return true; + } + + /** + * 플랫폼별 필수 조건 검증 + */ + public boolean isValidForPlatform() { + if ("INSTAGRAM".equals(platform)) { + // 인스타그램은 이미지가 권장됨 + return images != null && !images.isEmpty(); + } + if ("NAVER_BLOG".equals(platform)) { + // 네이버 블로그는 상세한 내용이 필요 + return requirement != null && requirement.length() >= 20; + } + return true; + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java new file mode 100644 index 0000000..0acf9ec --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -0,0 +1,383 @@ +package com.won.smarketing.content.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; +import java.util.List; + +/** + * SNS 콘텐츠 생성 응답 DTO + * + * AI를 통해 SNS 콘텐츠를 생성한 후 클라이언트에게 반환되는 응답 정보입니다. + * 생성된 콘텐츠의 기본 정보와 함께 사용자가 추가 편집할 수 있는 정보를 포함합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 응답") +public class SnsContentCreateResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "생성된 콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "대상 플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== AI 생성 콘텐츠 ==================== + + @Schema(description = "AI가 생성한 콘텐츠 제목", + example = "맛있는 신메뉴를 소개합니다! ✨") + private String title; + + @Schema(description = "AI가 생성한 콘텐츠 내용", + example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫") + private String content; + + @Schema(description = "AI가 생성한 해시태그 목록", + example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]") + private List hashtags; + + // ==================== 플랫폼별 최적화 정보 ==================== + + @Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280") + private Integer contentLength; + + @Schema(description = "플랫폼별 권장 해시태그 개수", example = "8") + private Integer recommendedHashtagCount; + + @Schema(description = "플랫폼별 최대 해시태그 개수", example = "15") + private Integer maxHashtagCount; + + // ==================== 생성 조건 정보 ==================== + + @Schema(description = "콘텐츠 생성에 사용된 조건들") + private GenerationConditionsDto generationConditions; + + // ==================== 상태 및 메타데이터 ==================== + + @Schema(description = "생성 상태", example = "DRAFT", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"}) + private String status; + + @Schema(description = "생성 일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "AI 모델 버전", example = "gpt-4-turbo") + private String aiModelVersion; + + @Schema(description = "생성 시간 (초)", example = "3.5") + private Double generationTimeSeconds; + + // ==================== 추가 정보 ==================== + + @Schema(description = "업로드된 원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85") + private Integer qualityScore; + + @Schema(description = "예상 참여율 (%)", example = "12.5") + private Double expectedEngagementRate; + + @Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개") + private String category; + + @Schema(description = "보정된 이미지 URL 목록") + private List fixedImages; + + // ==================== 편집 가능 여부 ==================== + + @Schema(description = "제목 편집 가능 여부", example = "true") + @Builder.Default + private Boolean titleEditable = true; + + @Schema(description = "내용 편집 가능 여부", example = "true") + @Builder.Default + private Boolean contentEditable = true; + + @Schema(description = "해시태그 편집 가능 여부", example = "true") + @Builder.Default + private Boolean hashtagsEditable = true; + + // ==================== 대안 콘텐츠 ==================== + + @Schema(description = "대안 제목 목록 (사용자 선택용)") + private List alternativeTitles; + + @Schema(description = "대안 해시태그 세트 목록") + private List> alternativeHashtagSets; + + // ==================== 내부 DTO 클래스 ==================== + + /** + * 콘텐츠 생성 조건 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class GenerationConditionsDto { + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 플랫폼별 콘텐츠 최적화 여부 확인 + * + * @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true + */ + public boolean isOptimizedForPlatform() { + if (content == null || hashtags == null) { + return false; + } + + // 플랫폼별 최적화 기준 + switch (platform.toUpperCase()) { + case "INSTAGRAM": + return content.length() <= 2200 && + hashtags.size() <= 15 && + hashtags.size() >= 5; + case "NAVER_BLOG": + return content.length() >= 300 && + hashtags.size() <= 10 && + hashtags.size() >= 3; + case "FACEBOOK": + return content.length() <= 500 && + hashtags.size() <= 5; + default: + return true; + } + } + + /** + * 고품질 콘텐츠 여부 확인 + * + * @return 품질 점수가 80점 이상이면 true + */ + public boolean isHighQuality() { + return qualityScore != null && qualityScore >= 80; + } + + /** + * 참여율 예상 등급 반환 + * + * @return 예상 참여율 등급 (HIGH, MEDIUM, LOW) + */ + public String getExpectedEngagementLevel() { + if (expectedEngagementRate == null) { + return "UNKNOWN"; + } + + if (expectedEngagementRate >= 15.0) { + return "HIGH"; + } else if (expectedEngagementRate >= 8.0) { + return "MEDIUM"; + } else { + return "LOW"; + } + } + + /** + * 해시태그를 문자열로 변환 (# 포함) + * + * @return #으로 시작하는 해시태그 문자열 + */ + public String getHashtagsAsString() { + if (hashtags == null || hashtags.isEmpty()) { + return ""; + } + + return hashtags.stream() + .map(tag -> "#" + tag) + .reduce((a, b) -> a + " " + b) + .orElse(""); + } + + /** + * 콘텐츠 요약 생성 + * + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public String getContentSummary(int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 플랫폼별 최적화 제안사항 반환 + * + * @return 최적화 제안사항 목록 + */ + public List getOptimizationSuggestions() { + List suggestions = new java.util.ArrayList<>(); + + if (!isOptimizedForPlatform()) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": + if (content != null && content.length() > 2200) { + suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() > 15) { + suggestions.add("해시태그를 15개 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() < 5) { + suggestions.add("해시태그를 5개 이상 추가해주세요."); + } + break; + case "NAVER_BLOG": + if (content != null && content.length() < 300) { + suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요."); + } + if (hashtags != null && hashtags.size() > 10) { + suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다."); + } + break; + case "FACEBOOK": + if (content != null && content.length() > 500) { + suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다."); + } + break; + } + } + + return suggestions; + } + + // ==================== 팩토리 메서드 ==================== + + /** + * 도메인 엔티티에서 SnsContentCreateResponse 생성 + * + * @param content 콘텐츠 도메인 엔티티 + * @param aiMetadata AI 생성 메타데이터 + * @return SnsContentCreateResponse + */ + public static SnsContentCreateResponse fromDomain( + com.won.smarketing.content.domain.model.Content content, + AiGenerationMetadata aiMetadata) { + + SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .originalImages(content.getImages()); + + // 생성 조건 정보 설정 + if (content.getCreationConditions() != null) { + builder.generationConditions(GenerationConditionsDto.builder() + //.targetAudience(content.getCreationConditions().getTargetAudience()) + .eventName(content.getCreationConditions().getEventName()) + .toneAndManner(content.getCreationConditions().getToneAndManner()) + .promotionType(content.getCreationConditions().getPromotionType()) + .emotionIntensity(content.getCreationConditions().getEmotionIntensity()) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .build()); + } + + // AI 메타데이터 설정 + if (aiMetadata != null) { + builder.aiModelVersion(aiMetadata.getModelVersion()) + .generationTimeSeconds(aiMetadata.getGenerationTime()) + .qualityScore(aiMetadata.getQualityScore()) + .expectedEngagementRate(aiMetadata.getExpectedEngagementRate()) + .alternativeTitles(aiMetadata.getAlternativeTitles()) + .alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets()); + } + + // 플랫폼별 최적화 정보 설정 + SnsContentCreateResponse response = builder.build(); + response.setContentLength(response.getContent() != null ? response.getContent().length() : 0); + response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name())); + response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name())); + + return response; + } + + /** + * 플랫폼별 권장 해시태그 개수 반환 + */ + private static Integer getRecommendedHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 8; + case "NAVER_BLOG": return 5; + case "FACEBOOK": return 3; + case "KAKAO_STORY": return 5; + default: return 5; + } + } + + /** + * 플랫폼별 최대 해시태그 개수 반환 + */ + private static Integer getMaxHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 15; + case "NAVER_BLOG": return 10; + case "FACEBOOK": return 5; + case "KAKAO_STORY": return 8; + default: return 10; + } + } + + // ==================== AI 생성 메타데이터 DTO ==================== + + /** + * AI 생성 메타데이터 + * AI 생성 과정에서 나온 부가 정보들 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AiGenerationMetadata { + private String modelVersion; + private Double generationTime; + private Integer qualityScore; + private Double expectedEngagementRate; + private List alternativeTitles; + private List> alternativeHashtagSets; + private String category; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java new file mode 100644 index 0000000..9adb6c8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java @@ -0,0 +1,79 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "SNS 콘텐츠 저장 요청") +public class SnsContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "플랫폼", example = "INSTAGRAM", required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!") + private String finalTitle; + + @Schema(description = "최종 콘텐츠 내용") + private String finalContent; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "메뉴소개") + private String category; + + @Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml new file mode 100644 index 0000000..10dc73d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${SERVER_PORT:8083} + +spring: + application: + name: marketing-content-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + +logging: + level: + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-java/member/build.gradle b/smarketing-java/member/build.gradle new file mode 100644 index 0000000..c75e760 --- /dev/null +++ b/smarketing-java/member/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation project(':common') + // 데이터베이스 의존성 + runtimeOnly 'org.postgresql:postgresql' +} \ No newline at end of file diff --git a/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java similarity index 100% rename from member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java new file mode 100644 index 0000000..4d5037a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.member.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/member/src/main/java/com/won/smarketing/member/controller/AuthController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java similarity index 68% rename from member/src/main/java/com/won/smarketing/member/controller/AuthController.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java index 15e3ba7..d3b1155 100644 --- a/member/src/main/java/com/won/smarketing/member/controller/AuthController.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java @@ -1,25 +1,21 @@ package com.won.smarketing.member.controller; import com.won.smarketing.common.dto.ApiResponse; -import com.won.smarketing.member.dto.LoginRequest; -import com.won.smarketing.member.dto.LoginResponse; -import com.won.smarketing.member.dto.LogoutRequest; -import com.won.smarketing.member.dto.TokenRefreshRequest; -import com.won.smarketing.member.dto.TokenResponse; +import com.won.smarketing.member.dto.*; import com.won.smarketing.member.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; /** - * 인증/인가를 위한 REST API 컨트롤러 + * 인증을 위한 REST API 컨트롤러 * 로그인, 로그아웃, 토큰 갱신 기능 제공 */ -@Tag(name = "인증/인가", description = "로그인, 로그아웃, 토큰 관리 API") +@Tag(name = "인증 관리", description = "로그인, 로그아웃, 토큰 관리 API") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor @@ -28,12 +24,12 @@ public class AuthController { private final AuthService authService; /** - * 로그인 인증 + * 로그인 * * @param request 로그인 요청 정보 - * @return JWT 토큰 정보 + * @return 로그인 성공 응답 (토큰 포함) */ - @Operation(summary = "로그인", description = "사용자 인증 후 JWT 토큰을 발급합니다.") + @Operation(summary = "로그인", description = "사용자 ID와 패스워드로 로그인합니다.") @PostMapping("/login") public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { LoginResponse response = authService.login(request); @@ -41,12 +37,12 @@ public class AuthController { } /** - * 로그아웃 처리 + * 로그아웃 * * @param request 로그아웃 요청 정보 * @return 로그아웃 성공 응답 */ - @Operation(summary = "로그아웃", description = "사용자를 로그아웃하고 토큰을 무효화합니다.") + @Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하여 로그아웃합니다.") @PostMapping("/logout") public ResponseEntity> logout(@Valid @RequestBody LogoutRequest request) { authService.logout(request.getRefreshToken()); @@ -57,9 +53,9 @@ public class AuthController { * 토큰 갱신 * * @param request 토큰 갱신 요청 정보 - * @return 새로운 JWT 토큰 정보 + * @return 새로운 토큰 정보 */ - @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급합니다.") + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") @PostMapping("/refresh") public ResponseEntity> refresh(@Valid @RequestBody TokenRefreshRequest request) { TokenResponse response = authService.refresh(request.getRefreshToken()); diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java new file mode 100644 index 0000000..e73728d --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java @@ -0,0 +1,120 @@ +package com.won.smarketing.member.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.member.dto.DuplicateCheckResponse; +import com.won.smarketing.member.dto.PasswordValidationRequest; +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; +import com.won.smarketing.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 회원 관리를 위한 REST API 컨트롤러 + * 회원가입, 중복 확인, 패스워드 검증 기능 제공 + */ +@Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API") +@RestController +@RequestMapping("/api/member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** + * 회원가입 + * + * @param request 회원가입 요청 정보 + * @return 회원가입 성공 응답 + */ + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + memberService.register(request); + return ResponseEntity.ok(ApiResponse.success(null, "회원가입이 완료되었습니다.")); + } + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 확인 결과 + */ + @Operation(summary = "사용자 ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/user-id") + public ResponseEntity> checkUserIdDuplicate( + @Parameter(description = "확인할 사용자 ID", required = true) + @RequestParam String userId) { + + boolean isDuplicate = memberService.checkDuplicate(userId); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 사용자 ID입니다.") + : DuplicateCheckResponse.available("사용 가능한 사용자 ID입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 확인 결과 + */ + @Operation(summary = "이메일 중복 확인", description = "이메일의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/email") + public ResponseEntity> checkEmailDuplicate( + @Parameter(description = "확인할 이메일", required = true) + @RequestParam String email) { + + boolean isDuplicate = memberService.checkEmailDuplicate(email); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 이메일입니다.") + : DuplicateCheckResponse.available("사용 가능한 이메일입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 확인 결과 + */ + @Operation(summary = "사업자번호 중복 확인", description = "사업자번호의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/business-number") + public ResponseEntity> checkBusinessNumberDuplicate( + @Parameter(description = "확인할 사업자번호", required = true) + @RequestParam String businessNumber) { + + boolean isDuplicate = memberService.checkBusinessNumberDuplicate(businessNumber); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 등록된 사업자번호입니다.") + : DuplicateCheckResponse.available("사용 가능한 사업자번호입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 패스워드 유효성 검증 + * + * @param request 패스워드 검증 요청 + * @return 패스워드 검증 결과 + */ + @Operation(summary = "패스워드 검증", description = "패스워드가 규칙을 만족하는지 확인합니다.") + @PostMapping("/validate-password") + public ResponseEntity> validatePassword( + @Valid @RequestBody PasswordValidationRequest request) { + + ValidationResponse response = memberService.validatePassword(request.getPassword()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} + + + diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java new file mode 100644 index 0000000..cf9e56b --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java @@ -0,0 +1,54 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 중복 확인 응답 DTO + * 사용자 ID, 이메일 등의 중복 확인 결과를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "중복 확인 응답") +public class DuplicateCheckResponse { + + @Schema(description = "중복 여부", example = "false") + private boolean isDuplicate; + + @Schema(description = "메시지", example = "사용 가능한 ID입니다.") + private String message; + + /** + * 중복된 경우의 응답 생성 + * + * @param message 메시지 + * @return 중복 응답 + */ + public static DuplicateCheckResponse duplicate(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(true) + .message(message) + .build(); + } + + /** + * 사용 가능한 경우의 응답 생성 + * + * @param message 메시지 + * @return 사용 가능 응답 + */ + public static DuplicateCheckResponse available(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(false) + .message(message) + .build(); + } +} + + + diff --git a/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java similarity index 52% rename from member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java index 7c304d0..d55ee0a 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java @@ -1,29 +1,26 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; - /** * 로그인 요청 DTO - * 로그인 시 필요한 사용자 ID와 패스워드 정보 + * 로그인 시 필요한 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "로그인 요청 정보") +@Schema(description = "로그인 요청") public class LoginRequest { - - @Schema(description = "사용자 ID", example = "testuser", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") private String userId; - + @Schema(description = "패스워드", example = "password123!", required = true) - @NotBlank(message = "패스워드는 필수입니다.") + @NotBlank(message = "패스워드는 필수입니다") private String password; } diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java new file mode 100644 index 0000000..3c71e94 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java @@ -0,0 +1,47 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 로그인 응답 DTO + * 로그인 성공 시 토큰 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그인 응답") +public class LoginResponse { + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") + private long expiresIn; + + @Schema(description = "사용자 정보") + private UserInfo userInfo; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "사용자 정보") + public static class UserInfo { + @Schema(description = "사용자 ID", example = "user123") + private String userId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + } +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java similarity index 93% rename from member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java index d53f388..99008bf 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java @@ -6,7 +6,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotBlank; /** * 로그아웃 요청 DTO diff --git a/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java similarity index 56% rename from member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java index f4a015b..b2d96aa 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java @@ -1,25 +1,22 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; - /** - * 패스워드 유효성 검증 요청 DTO - * 패스워드 보안 규칙 확인을 위한 요청 정보 + * 패스워드 검증 요청 DTO + * 패스워드 규칙 검증을 위한 요청 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder -@Schema(description = "패스워드 유효성 검증 요청") +@Schema(description = "패스워드 검증 요청") public class PasswordValidationRequest { - + @Schema(description = "검증할 패스워드", example = "password123!", required = true) - @NotBlank(message = "패스워드는 필수입니다.") + @NotBlank(message = "패스워드는 필수입니다") private String password; } diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java new file mode 100644 index 0000000..8543029 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java @@ -0,0 +1,49 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 회원가입 요청 DTO + * 회원가입 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회원가입 요청") +public class RegisterRequest { + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") + @Size(min = 4, max = 20, message = "사용자 ID는 4-20자 사이여야 합니다") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 사용 가능합니다") + private String userId; + + @Schema(description = "패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다") + @Size(min = 8, max = 20, message = "패스워드는 8-20자 사이여야 합니다") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$", + message = "패스워드는 영문, 숫자, 특수문자를 포함해야 합니다") + private String password; + + @Schema(description = "이름", example = "홍길동", required = true) + @NotBlank(message = "이름은 필수입니다") + @Size(max = 50, message = "이름은 50자 이하여야 합니다") + private String name; + + @Schema(description = "사업자등록번호", example = "123-45-67890") + @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)") + private String businessNumber; + + @Schema(description = "이메일", example = "user@example.com", required = true) + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + private String email; +} diff --git a/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java similarity index 53% rename from member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java index f62226b..7278ab5 100644 --- a/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java @@ -1,25 +1,22 @@ package com.won.smarketing.member.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; import lombok.AllArgsConstructor; -import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.NotBlank; - /** * 토큰 갱신 요청 DTO - * Refresh Token을 사용한 토큰 갱신 요청 정보 + * 리프레시 토큰을 사용한 액세스 토큰 갱신 요청 정보를 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor -@Builder @Schema(description = "토큰 갱신 요청") public class TokenRefreshRequest { - - @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) - @NotBlank(message = "Refresh Token은 필수입니다.") + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "리프레시 토큰은 필수입니다") private String refreshToken; } diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java new file mode 100644 index 0000000..a750def --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java @@ -0,0 +1,28 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 토큰 응답 DTO + * 토큰 갱신 시 새로운 토큰 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "토큰 응답") +public class TokenResponse { + + @Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "새로운 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") + private long expiresIn; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java new file mode 100644 index 0000000..4808fec --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java @@ -0,0 +1,58 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 검증 응답 DTO + * 패스워드 등의 검증 결과를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "검증 응답") +public class ValidationResponse { + + @Schema(description = "유효성 여부", example = "true") + private boolean isValid; + + @Schema(description = "메시지", example = "사용 가능한 패스워드입니다.") + private String message; + + @Schema(description = "오류 목록", example = "[\"영문이 포함되어야 합니다\", \"숫자가 포함되어야 합니다\"]") + private List errors; + + /** + * 유효한 경우의 응답 생성 + * + * @param message 메시지 + * @return 유효 응답 + */ + public static ValidationResponse valid(String message) { + return ValidationResponse.builder() + .isValid(true) + .message(message) + .build(); + } + + /** + * 유효하지 않은 경우의 응답 생성 + * + * @param message 메시지 + * @param errors 오류 목록 + * @return 무효 응답 + */ + public static ValidationResponse invalid(String message, List errors) { + return ValidationResponse.builder() + .isValid(false) + .message(message) + .errors(errors) + .build(); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java new file mode 100644 index 0000000..0dd68e2 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java @@ -0,0 +1,82 @@ +package com.won.smarketing.member.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 회원 엔티티 + * 회원의 기본 정보와 사업자 정보를 관리 + */ +@Entity +@Table(name = "members") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "user_id", nullable = false, unique = true, length = 50) + private String userId; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "business_number", length = 12) + private String businessNumber; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 회원 정보 업데이트 + * + * @param name 이름 + * @param email 이메일 + * @param businessNumber 사업자번호 + */ + public void updateProfile(String name, String email, String businessNumber) { + if (name != null && !name.trim().isEmpty()) { + this.name = name; + } + if (email != null && !email.trim().isEmpty()) { + this.email = email; + } + if (businessNumber != null && !businessNumber.trim().isEmpty()) { + this.businessNumber = businessNumber; + } + } + + /** + * 패스워드 변경 + * + * @param encodedPassword 암호화된 패스워드 + */ + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } +} diff --git a/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java similarity index 88% rename from member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java index c7b6d44..eec42ea 100644 --- a/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java @@ -17,7 +17,7 @@ public interface MemberRepository extends JpaRepository { * 사용자 ID로 회원 조회 * * @param userId 사용자 ID - * @return 회원 정보 + * @return 회원 정보 (Optional) */ Optional findByUserId(String userId); @@ -38,9 +38,9 @@ public interface MemberRepository extends JpaRepository { boolean existsByEmail(String email); /** - * 사업자 번호 존재 여부 확인 + * 사업자번호 존재 여부 확인 * - * @param businessNumber 사업자 번호 + * @param businessNumber 사업자번호 * @return 존재 여부 */ boolean existsByBusinessNumber(String businessNumber); diff --git a/member/src/main/java/com/won/smarketing/member/service/AuthService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java similarity index 57% rename from member/src/main/java/com/won/smarketing/member/service/AuthService.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java index f93b0b7..c73bc1f 100644 --- a/member/src/main/java/com/won/smarketing/member/service/AuthService.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java @@ -5,31 +5,31 @@ import com.won.smarketing.member.dto.LoginResponse; import com.won.smarketing.member.dto.TokenResponse; /** - * 인증/인가 서비스 인터페이스 - * 로그인, 로그아웃, 토큰 갱신 기능 정의 + * 인증 서비스 인터페이스 + * 로그인, 로그아웃, 토큰 갱신 관련 비즈니스 로직 정의 */ public interface AuthService { /** - * 로그인 인증 처리 + * 로그인 * * @param request 로그인 요청 정보 - * @return JWT 토큰 정보 + * @return 로그인 응답 정보 (토큰 포함) */ LoginResponse login(LoginRequest request); /** - * 로그아웃 처리 + * 로그아웃 * - * @param refreshToken 무효화할 Refresh Token + * @param refreshToken 리프레시 토큰 */ void logout(String refreshToken); /** - * 토큰 갱신 처리 + * 토큰 갱신 * - * @param refreshToken 갱신에 사용할 Refresh Token - * @return 새로운 JWT 토큰 정보 + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 */ TokenResponse refresh(String refreshToken); } diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java new file mode 100644 index 0000000..c01646f --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java @@ -0,0 +1,175 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.common.security.JwtTokenProvider; +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.TokenResponse; +import com.won.smarketing.member.entity.Member; +import com.won.smarketing.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.TimeUnit; + +/** + * 인증 서비스 구현체 + * 로그인, 로그아웃, 토큰 갱신 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RedisTemplate redisTemplate; + + private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; + private static final String BLACKLIST_PREFIX = "blacklist:"; + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 응답 정보 (토큰 포함) + */ + @Override + @Transactional + public LoginResponse login(LoginRequest request) { + log.info("로그인 시도: {}", request.getUserId()); + + // 회원 조회 + Member member = memberRepository.findByUserId(request.getUserId()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 패스워드 검증 + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + throw new BusinessException(ErrorCode.INVALID_PASSWORD); + } + + // 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); + + // 리프레시 토큰을 Redis에 저장 (7일) + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + member.getUserId(), + refreshToken, + 7, + TimeUnit.DAYS + ); + + log.info("로그인 성공: {}", request.getUserId()); + + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .userInfo(LoginResponse.UserInfo.builder() + .userId(member.getUserId()) + .name(member.getName()) + .email(member.getEmail()) + .build()) + .build(); + } + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + */ + @Override + @Transactional + public void logout(String refreshToken) { + try { + if (jwtTokenProvider.validateToken(refreshToken)) { + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에서 리프레시 토큰 삭제 + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + + // 리프레시 토큰을 블랙리스트에 추가 + redisTemplate.opsForValue().set( + BLACKLIST_PREFIX + refreshToken, + "logout", + 7, + TimeUnit.DAYS + ); + + log.info("로그아웃 완료: {}", userId); + } + } catch (Exception ex) { + log.warn("로그아웃 처리 중 오류 발생: {}", ex.getMessage()); + // 로그아웃은 실패해도 클라이언트에게는 성공으로 응답 + } + } + + /** + * 토큰 갱신 + * + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 + */ + @Override + @Transactional + public TokenResponse refresh(String refreshToken) { + // 토큰 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 블랙리스트 확인 + if (redisTemplate.hasKey(BLACKLIST_PREFIX + refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에 저장된 리프레시 토큰과 비교 + String storedRefreshToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + if (!refreshToken.equals(storedRefreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 회원 존재 확인 + if (!memberRepository.existsByUserId(userId)) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + + // 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + + // 새로운 리프레시 토큰을 Redis에 저장 + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + userId, + newRefreshToken, + 7, + TimeUnit.DAYS + ); + + // 기존 리프레시 토큰을 블랙리스트에 추가 + redisTemplate.opsForValue().set( + BLACKLIST_PREFIX + refreshToken, + "refreshed", + 7, + TimeUnit.DAYS + ); + + log.info("토큰 갱신 완료: {}", userId); + + return TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .build(); + } +} \ No newline at end of file diff --git a/member/src/main/java/com/won/smarketing/member/service/MemberService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java similarity index 53% rename from member/src/main/java/com/won/smarketing/member/service/MemberService.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java index a2dc6c8..c1e456f 100644 --- a/member/src/main/java/com/won/smarketing/member/service/MemberService.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java @@ -4,13 +4,13 @@ import com.won.smarketing.member.dto.RegisterRequest; import com.won.smarketing.member.dto.ValidationResponse; /** - * 회원 관리 서비스 인터페이스 - * 회원가입, 중복 확인, 패스워드 유효성 검증 기능 정의 + * 회원 서비스 인터페이스 + * 회원 관리 관련 비즈니스 로직 정의 */ public interface MemberService { /** - * 회원가입 처리 + * 회원 등록 * * @param request 회원가입 요청 정보 */ @@ -20,15 +20,31 @@ public interface MemberService { * 사용자 ID 중복 확인 * * @param userId 확인할 사용자 ID - * @return 중복 여부 (true: 중복, false: 사용 가능) + * @return 중복 여부 */ boolean checkDuplicate(String userId); + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + boolean checkEmailDuplicate(String email); + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + boolean checkBusinessNumberDuplicate(String businessNumber); + /** * 패스워드 유효성 검증 * * @param password 검증할 패스워드 - * @return 유효성 검증 결과 + * @return 검증 결과 */ ValidationResponse validatePassword(String password); } diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..8c730d2 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java @@ -0,0 +1,146 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; +import com.won.smarketing.member.entity.Member; +import com.won.smarketing.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 회원 서비스 구현체 + * 회원 등록, 중복 확인, 패스워드 검증 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + // 패스워드 검증 패턴 + private static final Pattern LETTER_PATTERN = Pattern.compile(".*[a-zA-Z].*"); + private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*"); + private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[@$!%*?&].*"); + + /** + * 회원 등록 + * + * @param request 회원가입 요청 정보 + */ + @Override + @Transactional + public void register(RegisterRequest request) { + log.info("회원 등록 시작: {}", request.getUserId()); + + // 중복 확인 + if (memberRepository.existsByUserId(request.getUserId())) { + throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); + } + + if (memberRepository.existsByEmail(request.getEmail())) { + throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); + } + + if (request.getBusinessNumber() != null && + memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { + throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); + } + + // 회원 엔티티 생성 및 저장 + Member member = Member.builder() + .userId(request.getUserId()) + .password(passwordEncoder.encode(request.getPassword())) + .name(request.getName()) + .businessNumber(request.getBusinessNumber()) + .email(request.getEmail()) + .build(); + + memberRepository.save(member); + log.info("회원 등록 완료: {}", request.getUserId()); + } + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 + */ + @Override + public boolean checkDuplicate(String userId) { + return memberRepository.existsByUserId(userId); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + @Override + public boolean checkEmailDuplicate(String email) { + return memberRepository.existsByEmail(email); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + @Override + public boolean checkBusinessNumberDuplicate(String businessNumber) { + if (businessNumber == null || businessNumber.trim().isEmpty()) { + return false; + } + return memberRepository.existsByBusinessNumber(businessNumber); + } + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 검증 결과 + */ + @Override + public ValidationResponse validatePassword(String password) { + List errors = new ArrayList<>(); + + // 길이 검증 + if (password.length() < 8 || password.length() > 20) { + errors.add("패스워드는 8-20자 사이여야 합니다"); + } + + // 영문 포함 여부 + if (!LETTER_PATTERN.matcher(password).matches()) { + errors.add("영문이 포함되어야 합니다"); + } + + // 숫자 포함 여부 + if (!DIGIT_PATTERN.matcher(password).matches()) { + errors.add("숫자가 포함되어야 합니다"); + } + + // 특수문자 포함 여부 + if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) { + errors.add("특수문자(@$!%*?&)가 포함되어야 합니다"); + } + + if (errors.isEmpty()) { + return ValidationResponse.valid("사용 가능한 패스워드입니다."); + } else { + return ValidationResponse.invalid("패스워드 규칙을 확인해 주세요.", errors); + } + } +} diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml new file mode 100644 index 0000000..511b56f --- /dev/null +++ b/smarketing-java/member/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${MEMBER_PORT:8081} + +spring: + application: + name: member-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MemberDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6380} + password: ${REDIS_PASSWORD:} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + +logging: + level: + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/settings.gradle b/smarketing-java/settings.gradle similarity index 82% rename from settings.gradle rename to smarketing-java/settings.gradle index f31190a..54fbe0d 100644 --- a/settings.gradle +++ b/smarketing-java/settings.gradle @@ -1,7 +1,6 @@ rootProject.name = 'smarketing' - include 'common' include 'member' include 'store' include 'marketing-content' -include 'ai-recommend' +include 'ai-recommend' \ No newline at end of file diff --git a/smarketing-java/store/build.gradle b/smarketing-java/store/build.gradle new file mode 100644 index 0000000..771a2fc --- /dev/null +++ b/smarketing-java/store/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java new file mode 100644 index 0000000..3c7e2f9 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java @@ -0,0 +1,31 @@ +package com.won.smarketing.store.config; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { + private String category; + + @Schema(description = "가격", example = "4500", required = true) + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") + private String image; +} diff --git a/store/src/main/java/com/won/smarketing/store/controller/MenuController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java similarity index 99% rename from store/src/main/java/com/won/smarketing/store/controller/MenuController.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java index 9f87a8b..e5e3e4f 100644 --- a/store/src/main/java/com/won/smarketing/store/controller/MenuController.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; import java.util.List; /** diff --git a/store/src/main/java/com/won/smarketing/store/controller/SalesController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/controller/SalesController.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java diff --git a/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java similarity index 88% rename from store/src/main/java/com/won/smarketing/store/controller/StoreController.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java index f4dec35..348aa57 100644 --- a/store/src/main/java/com/won/smarketing/store/controller/StoreController.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java @@ -12,7 +12,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; +import jakarta.validation.Valid; /** * 매장 관리를 위한 REST API 컨트롤러 @@ -42,15 +42,16 @@ public class StoreController { /** * 매장 정보 조회 * - * @param storeId 조회할 매장 ID + * //@param userId 조회할 매장 ID * @return 매장 정보 */ - @Operation(summary = "매장 조회", description = "매장 ID로 매장 정보를 조회합니다.") + @Operation(summary = "매장 조회", description = "유저 ID로 매장 정보를 조회합니다.") @GetMapping public ResponseEntity> getStore( - @Parameter(description = "매장 ID", required = true) - @RequestParam String storeId) { - StoreResponse response = storeService.getStore(storeId); +// @Parameter(description = "유저 ID", required = true) +// @RequestParam String userId + ) { + StoreResponse response = storeService.getStore(); return ResponseEntity.ok(ApiResponse.success(response)); } diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java new file mode 100644 index 0000000..7cb3804 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java @@ -0,0 +1,49 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 메뉴 등록 요청 DTO + * 메뉴 등록 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴 등록 요청") +public class MenuCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "메뉴명", example = "아메리카노", required = true) + @NotBlank(message = "메뉴명은 필수입니다") + @Size(max = 100, message = "메뉴명은 100자 이하여야 합니다") + private String menuName; + + @Schema(description = "카테고리", example = "커피") + @Size(max = 50, message = "카테고리는 50자 이하여야 합니다") + private String category; + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") + private String image; +} + + + diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java similarity index 54% rename from store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java index 232556a..aa9f642 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java @@ -9,37 +9,41 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; /** - * 메뉴 정보 응답 DTO - * 메뉴 정보 조회/등록/수정 시 반환되는 데이터 + * 메뉴 응답 DTO + * 메뉴 정보를 클라이언트에게 전달합니다. */ @Data @NoArgsConstructor @AllArgsConstructor @Builder -@Schema(description = "메뉴 정보 응답") +@Schema(description = "메뉴 응답") public class MenuResponse { - + @Schema(description = "메뉴 ID", example = "1") private Long menuId; - + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + @Schema(description = "메뉴명", example = "아메리카노") private String menuName; - - @Schema(description = "메뉴 카테고리", example = "커피") + + @Schema(description = "카테고리", example = "커피") private String category; - + @Schema(description = "가격", example = "4500") private Integer price; - - @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") private String description; - - @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") private String image; - - @Schema(description = "등록 시각") + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") private LocalDateTime createdAt; - - @Schema(description = "수정 시각") + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") private LocalDateTime updatedAt; } + diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java similarity index 93% rename from store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index c10ac54..e597bc5 100644 --- a/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -6,8 +6,8 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import javax.validation.constraints.Min; -import javax.validation.constraints.Size; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; /** * 메뉴 수정 요청 DTO diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java new file mode 100644 index 0000000..3152582 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java @@ -0,0 +1,36 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +/** + * 매출 응답 DTO + * 매출 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매출 응답") +public class SalesResponse { + + @Schema(description = "오늘 매출", example = "150000") + private BigDecimal todaySales; + + @Schema(description = "월간 매출", example = "4500000") + private BigDecimal monthSales; + + @Schema(description = "전일 대비 매출 변화", example = "25000") + private BigDecimal previousDayComparison; + + @Schema(description = "전일 대비 매출 변화율 (%)", example = "15.5") + private BigDecimal previousDayChangeRate; + + @Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2") + private BigDecimal goalAchievementRate; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java new file mode 100644 index 0000000..71dd250 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java @@ -0,0 +1,58 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 등록 요청 DTO + * 매장 등록 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 등록 요청") +public class StoreCreateRequest { + + @Schema(description = "매장명", example = "맛있는 카페", required = true) + @NotBlank(message = "매장명은 필수입니다") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123", required = true) + @NotBlank(message = "주소는 필수입니다") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String snsAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; +} + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java new file mode 100644 index 0000000..f0b583a --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java @@ -0,0 +1,58 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 매장 응답 DTO + * 매장 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 응답") +public class StoreResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "맛있는 카페") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + private String snsAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + private String description; + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java new file mode 100644 index 0000000..4592a6f --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java @@ -0,0 +1,55 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 수정 요청 DTO + * 매장 정보 수정 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 수정 요청") +public class StoreUpdateRequest { + + @Schema(description = "매장명", example = "맛있는 카페") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String snsAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; +} + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java new file mode 100644 index 0000000..83bb830 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java @@ -0,0 +1,81 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 메뉴 엔티티 + * 매장의 메뉴 정보를 관리 + */ +@Entity +@Table(name = "menus") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Menu { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_id") + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "menu_name", nullable = false, length = 100) + private String menuName; + + @Column(name = "category", length = 50) + private String category; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "image_url", length = 500) + private String image; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 메뉴 정보 업데이트 + * + * @param menuName 메뉴명 + * @param category 카테고리 + * @param price 가격 + * @param description 설명 + * @param image 이미지 URL + */ + public void updateMenu(String menuName, String category, Integer price, + String description, String image) { + if (menuName != null && !menuName.trim().isEmpty()) { + this.menuName = menuName; + } + if (category != null && !category.trim().isEmpty()) { + this.category = category; + } + if (price != null && price > 0) { + this.price = price; + } + this.description = description; + this.image = image; + } +} diff --git a/store/src/main/java/com/won/smarketing/store/entity/Sales.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java similarity index 99% rename from store/src/main/java/com/won/smarketing/store/entity/Sales.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java index 5398ae2..91e74c7 100644 --- a/store/src/main/java/com/won/smarketing/store/entity/Sales.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java @@ -59,3 +59,4 @@ public class Sales { createdAt = LocalDateTime.now(); } } + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java new file mode 100644 index 0000000..21efeec --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java @@ -0,0 +1,103 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * 매장 엔티티 + * 매장의 기본 정보와 운영 정보를 관리 + */ +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Store { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id") + private Long id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "store_name", nullable = false, length = 100) + private String storeName; + + @Column(name = "business_type", length = 50) + private String businessType; + + @Column(name = "address", nullable = false, length = 200) + private String address; + + @Column(name = "phone_number", length = 20) + private String phoneNumber; + + @Column(name = "business_hours", length = 100) + private String businessHours; + + @Column(name = "closed_days", length = 100) + private String closedDays; + + @Column(name = "seat_count") + private Integer seatCount; + + @Column(name = "sns_accounts", length = 500) + private String snsAccounts; + + @Column(name = "description", length = 1000) + private String description; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 매장 정보 업데이트 + * + * @param storeName 매장명 + * @param businessType 업종 + * @param address 주소 + * @param phoneNumber 전화번호 + * @param businessHours 영업시간 + * @param closedDays 휴무일 + * @param seatCount 좌석 수 + * @param snsAccounts SNS 계정 정보 + * @param description 설명 + */ + public void updateStore(String storeName, String businessType, String address, + String phoneNumber, String businessHours, String closedDays, + Integer seatCount, String snsAccounts, String description) { + if (storeName != null && !storeName.trim().isEmpty()) { + this.storeName = storeName; + } + if (businessType != null && !businessType.trim().isEmpty()) { + this.businessType = businessType; + } + if (address != null && !address.trim().isEmpty()) { + this.address = address; + } + this.phoneNumber = phoneNumber; + this.businessHours = businessHours; + this.closedDays = closedDays; + this.seatCount = seatCount; + this.snsAccounts = snsAccounts; + this.description = description; + } +} diff --git a/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java similarity index 100% rename from store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java new file mode 100644 index 0000000..f34b853 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java @@ -0,0 +1,67 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Sales; +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.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 매출 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매출 조회 작업 처리 + */ +@Repository +public interface SalesRepository extends JpaRepository { + + /** + * 매장의 특정 날짜 매출 조회 + * + * @param storeId 매장 ID + * @param salesDate 매출 날짜 + * @return 해당 날짜 매출 목록 + */ + List findByStoreIdAndSalesDate(Long storeId, LocalDate salesDate); + + /** + * 매장의 특정 기간 매출 조회 + * + * @param storeId 매장 ID + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 해당 기간 매출 목록 + */ + List findByStoreIdAndSalesDateBetween(Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * 매장의 오늘 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 오늘 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE", nativeQuery = true) + BigDecimal findTodaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 어제 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 어제 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE - INTERVAL '1 day'", nativeQuery = true) + BigDecimal findYesterdaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 이번 달 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId " + + "AND EXTRACT(YEAR FROM sales_date) = EXTRACT(YEAR FROM CURRENT_DATE) " + + "AND EXTRACT(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true) + BigDecimal findMonthSalesByStoreIdNative(@Param("storeId") Long storeId); +} \ No newline at end of file diff --git a/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java similarity index 51% rename from store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java index 97a26db..4fbbcea 100644 --- a/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java @@ -14,10 +14,29 @@ import java.util.Optional; public interface StoreRepository extends JpaRepository { /** - * 사용자 ID로 매장 조회 + * 회원 ID로 매장 조회 * - * @param userId 사용자 ID - * @return 매장 정보 + * @param userId 회원 ID + * @return 매장 정보 (Optional) */ Optional findByUserId(String userId); + + /** + * 회원의 매장 존재 여부 확인 + * + * @param userId 회원 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 매장명으로 매장 조회 + * + * @param storeName 매장명 + * @return 매장 목록 + */ + Optional findByStoreName(String storeName); } + + + diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java similarity index 81% rename from store/src/main/java/com/won/smarketing/store/service/MenuService.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java index e15141b..764ad24 100644 --- a/store/src/main/java/com/won/smarketing/store/service/MenuService.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java @@ -7,13 +7,13 @@ import com.won.smarketing.store.dto.MenuUpdateRequest; import java.util.List; /** - * 메뉴 관리 서비스 인터페이스 - * 메뉴 등록, 조회, 수정, 삭제 기능 정의 + * 메뉴 서비스 인터페이스 + * 메뉴 관리 관련 비즈니스 로직 정의 */ public interface MenuService { /** - * 메뉴 정보 등록 + * 메뉴 등록 * * @param request 메뉴 등록 요청 정보 * @return 등록된 메뉴 정보 @@ -31,7 +31,7 @@ public interface MenuService { /** * 메뉴 정보 수정 * - * @param menuId 수정할 메뉴 ID + * @param menuId 메뉴 ID * @param request 메뉴 수정 요청 정보 * @return 수정된 메뉴 정보 */ @@ -40,7 +40,7 @@ public interface MenuService { /** * 메뉴 삭제 * - * @param menuId 삭제할 메뉴 ID + * @param menuId 메뉴 ID */ void deleteMenu(Long menuId); } diff --git a/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java similarity index 99% rename from store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java index 3c66c15..7c31341 100644 --- a/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -83,7 +83,7 @@ public class MenuServiceImpl implements MenuService { .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); // 메뉴 정보 업데이트 - menu.updateMenuInfo( + menu.updateMenu( request.getMenuName(), request.getCategory(), request.getPrice(), diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java similarity index 62% rename from store/src/main/java/com/won/smarketing/store/service/SalesService.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java index 5d92c5e..c077a9d 100644 --- a/store/src/main/java/com/won/smarketing/store/service/SalesService.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java @@ -3,15 +3,15 @@ package com.won.smarketing.store.service; import com.won.smarketing.store.dto.SalesResponse; /** - * 매출 관리 서비스 인터페이스 - * 매출 조회 기능 정의 + * 매출 서비스 인터페이스 + * 매출 조회 관련 비즈니스 로직 정의 */ public interface SalesService { /** * 매출 정보 조회 * - * @return 매출 정보 (오늘, 월간, 전일 대비) + * @return 매출 정보 */ SalesResponse getSales(); } diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java new file mode 100644 index 0000000..ded5564 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java @@ -0,0 +1,84 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.entity.Sales; +import com.won.smarketing.store.repository.SalesRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 매출 관리 서비스 구현체 + * 매출 조회 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SalesServiceImpl implements SalesService { + + private final SalesRepository salesRepository; + + /** + * 매출 정보 조회 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Override + public SalesResponse getSales() { + // TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 함 + Long storeId = 1L; // 임시로 설정 + + // 오늘 매출 계산 + BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now()); + + // 이번 달 매출 계산 + BigDecimal monthSales = calculateMonthSales(storeId); + + // 어제 매출 계산 + BigDecimal yesterdaySales = calculateSalesByDate(storeId, LocalDate.now().minusDays(1)); + + // 전일 대비 매출 변화량 계산 + BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales); + + return SalesResponse.builder() + .todaySales(todaySales) + .monthSales(monthSales) + .previousDayComparison(previousDayComparison) + .build(); + } + + /** + * 특정 날짜의 매출 계산 + * + * @param storeId 매장 ID + * @param date 날짜 + * @return 해당 날짜 매출 + */ + private BigDecimal calculateSalesByDate(Long storeId, LocalDate date) { + List salesList = salesRepository.findByStoreIdAndSalesDate(storeId, date); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 이번 달 매출 계산 + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + private BigDecimal calculateMonthSales(Long storeId) { + LocalDate now = LocalDate.now(); + LocalDate startOfMonth = now.withDayOfMonth(1); + LocalDate endOfMonth = now.withDayOfMonth(now.lengthOfMonth()); + + List salesList = salesRepository.findByStoreIdAndSalesDateBetween(storeId, startOfMonth, endOfMonth); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} \ No newline at end of file diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java similarity index 64% rename from store/src/main/java/com/won/smarketing/store/service/StoreService.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java index c00b924..ffd4a5f 100644 --- a/store/src/main/java/com/won/smarketing/store/service/StoreService.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java @@ -5,13 +5,13 @@ import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreUpdateRequest; /** - * 매장 관리 서비스 인터페이스 - * 매장 등록, 조회, 수정 기능 정의 + * 매장 서비스 인터페이스 + * 매장 관리 관련 비즈니스 로직 정의 */ public interface StoreService { /** - * 매장 정보 등록 + * 매장 등록 * * @param request 매장 등록 요청 정보 * @return 등록된 매장 정보 @@ -19,17 +19,24 @@ public interface StoreService { StoreResponse register(StoreCreateRequest request); /** - * 매장 정보 조회 + * 매장 정보 조회 (현재 로그인 사용자) * - * @param storeId 조회할 매장 ID * @return 매장 정보 */ - StoreResponse getStore(String storeId); + StoreResponse getMyStore(); + + /** + * 매장 정보 조회 (매장 ID) + * + * //@param userId 매장 ID + * @return 매장 정보 + */ + StoreResponse getStore(); /** * 매장 정보 수정 * - * @param storeId 수정할 매장 ID + * @param storeId 매장 ID * @param request 매장 수정 요청 정보 * @return 수정된 매장 정보 */ diff --git a/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java similarity index 56% rename from store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java rename to smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java index fe0d505..4ddd56a 100644 --- a/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java @@ -7,14 +7,19 @@ import com.won.smarketing.store.dto.StoreResponse; import com.won.smarketing.store.dto.StoreUpdateRequest; import com.won.smarketing.store.entity.Store; import com.won.smarketing.store.repository.StoreRepository; +import jakarta.xml.bind.annotation.XmlType; +import lombok.Builder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * 매장 관리 서비스 구현체 + * 매장 서비스 구현체 * 매장 등록, 조회, 수정 기능 구현 */ +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,7 +28,7 @@ public class StoreServiceImpl implements StoreService { private final StoreRepository storeRepository; /** - * 매장 정보 등록 + * 매장 등록 * * @param request 매장 등록 요청 정보 * @return 등록된 매장 정보 @@ -31,50 +36,75 @@ public class StoreServiceImpl implements StoreService { @Override @Transactional public StoreResponse register(StoreCreateRequest request) { - // 사용자별 매장 중복 등록 확인 - if (storeRepository.findByUserId(request.getUserId()).isPresent()) { + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 + + log.info("매장 등록 시작: {} (회원: {})", request.getStoreName(), memberId); + + // 회원당 하나의 매장만 등록 가능 + if (storeRepository.existsByUserId(memberId)) { throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); } - + // 매장 엔티티 생성 및 저장 Store store = Store.builder() - .userId(request.getUserId()) + .userId(memberId) .storeName(request.getStoreName()) - .storeImage(request.getStoreImage()) .businessType(request.getBusinessType()) .address(request.getAddress()) .phoneNumber(request.getPhoneNumber()) - .businessNumber(request.getBusinessNumber()) - .instaAccount(request.getInstaAccount()) - .naverBlogAccount(request.getNaverBlogAccount()) - .openTime(request.getOpenTime()) - .closeTime(request.getCloseTime()) + .businessHours(request.getBusinessHours()) .closedDays(request.getClosedDays()) .seatCount(request.getSeatCount()) + .snsAccounts(request.getSnsAccounts()) + .description(request.getDescription()) .build(); - + Store savedStore = storeRepository.save(store); + log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId()); + return toStoreResponse(savedStore); } /** - * 매장 정보 조회 + * 매장 정보 조회 (현재 로그인 사용자) * - * @param storeId 조회할 매장 ID * @return 매장 정보 */ @Override - public StoreResponse getStore(String storeId) { - Store store = storeRepository.findByUserId(storeId) + public StoreResponse getMyStore() { + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); + + Store store = storeRepository.findByUserId(memberId) .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); return toStoreResponse(store); } + /** + * 매장 정보 조회 (매장 ID) + * + * //@param storeId 매장 ID + * @return 매장 정보 + */ + @Override + public StoreResponse getStore() { + try { + String userId = getCurrentUserId(); + Store store = storeRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } + /** * 매장 정보 수정 * - * @param storeId 수정할 매장 ID + * @param storeId 매장 ID * @param request 매장 수정 요청 정보 * @return 수정된 매장 정보 */ @@ -83,22 +113,23 @@ public class StoreServiceImpl implements StoreService { public StoreResponse updateStore(Long storeId, StoreUpdateRequest request) { Store store = storeRepository.findById(storeId) .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); - + // 매장 정보 업데이트 - store.updateStoreInfo( + store.updateStore( request.getStoreName(), - request.getStoreImage(), + request.getBusinessType(), request.getAddress(), request.getPhoneNumber(), - request.getInstaAccount(), - request.getNaverBlogAccount(), - request.getOpenTime(), - request.getCloseTime(), + request.getBusinessHours(), request.getClosedDays(), - request.getSeatCount() + request.getSeatCount(), + request.getSnsAccounts(), + request.getDescription() ); - + Store updatedStore = storeRepository.save(store); + log.info("매장 정보 수정 완료: {} (ID: {})", updatedStore.getStoreName(), updatedStore.getId()); + return toStoreResponse(updatedStore); } @@ -112,19 +143,25 @@ public class StoreServiceImpl implements StoreService { return StoreResponse.builder() .storeId(store.getId()) .storeName(store.getStoreName()) - .storeImage(store.getStoreImage()) .businessType(store.getBusinessType()) .address(store.getAddress()) .phoneNumber(store.getPhoneNumber()) - .businessNumber(store.getBusinessNumber()) - .instaAccount(store.getInstaAccount()) - .naverBlogAccount(store.getNaverBlogAccount()) - .openTime(store.getOpenTime()) - .closeTime(store.getCloseTime()) + .businessHours(store.getBusinessHours()) .closedDays(store.getClosedDays()) .seatCount(store.getSeatCount()) + .snsAccounts(store.getSnsAccounts()) + .description(store.getDescription()) .createdAt(store.getCreatedAt()) .updatedAt(store.getUpdatedAt()) .build(); } + + /** + * 현재 로그인된 사용자 ID 조회 + * + * @return 사용자 ID + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } } diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml new file mode 100644 index 0000000..8e20d9c --- /dev/null +++ b/smarketing-java/store/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${SERVER_PORT:8082} + +spring: + application: + name: store-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:StoreDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +logging: + level: + com.won.smarketing.store: ${LOG_LEVEL:DEBUG} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file diff --git a/store/build.gradle b/store/build.gradle deleted file mode 100644 index b96273e..0000000 --- a/store/build.gradle +++ /dev/null @@ -1,7 +0,0 @@ -dependencies { - implementation project(':common') -} - -bootJar { - archiveFileName = "store-service.jar" -} diff --git a/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java deleted file mode 100644 index d800e7d..0000000 --- a/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.won.smarketing.store.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.Min; -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; - -/** - * 메뉴 등록 요청 DTO - * 메뉴 등록 시 필요한 정보를 담는 데이터 전송 객체 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "메뉴 등록 요청 정보") -public class MenuCreateRequest { - - @Schema(description = "매장 ID", example = "1", required = true) - @NotNull(message = "매장 ID는 필수입니다.") - private Long storeId; - - @Schema(description = "메뉴명", example = "아메리카노", required = true) - @NotBlank(message = "메뉴명은 필수입니다.") - @Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") - private String menuName; - - @Schema(description = "메뉴 카테고리", example = "커피", required = true) - @NotBlank(message = "카테고리는 필수입니다.") - @Size(max = 100, message = "카테고리는 100자 이하여야 합니다.") - private String category; - - @Schema(description = "가격", example = "4500", required = true) - @NotNull(message = "가격은 필수입니다.") - @Min(value = 0, message = "가격은 0 이상이어야 합니다.") - private Integer price; - - @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") - private String description; - - @Schema(description = "메뉴 이미지 URL", example = "https://example.com/americano.jpg") - private String image; -} diff --git a/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java deleted file mode 100644 index 4fcfa9b..0000000 --- a/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.won.smarketing.store.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.math.BigDecimal; - -/** - * 매출 정보 응답 DTO - * 오늘 매출, 월간 매출, 전일 대비 매출 정보 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "매출 정보 응답") -public class SalesResponse { - - @Schema(description = "오늘 매출", example = "150000") - private BigDecimal todaySales; - - @Schema(description = "이번 달 매출", example = "3200000") - private BigDecimal monthSales; - - @Schema(description = "전일 대비 매출 변화량", example = "25000") - private BigDecimal previousDayComparison; -} diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java deleted file mode 100644 index 9b56620..0000000 --- a/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.won.smarketing.store.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; - -/** - * 매장 등록 요청 DTO - * 매장 등록 시 필요한 정보를 담는 데이터 전송 객체 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "매장 등록 요청 정보") -public class StoreCreateRequest { - - @Schema(description = "매장 소유자 사용자 ID", example = "testuser", required = true) - @NotBlank(message = "사용자 ID는 필수입니다.") - private String userId; - - @Schema(description = "매장명", example = "맛있는 카페", required = true) - @NotBlank(message = "매장명은 필수입니다.") - @Size(max = 200, message = "매장명은 200자 이하여야 합니다.") - private String storeName; - - @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") - private String storeImage; - - @Schema(description = "업종", example = "카페", required = true) - @NotBlank(message = "업종은 필수입니다.") - @Size(max = 100, message = "업종은 100자 이하여야 합니다.") - private String businessType; - - @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123", required = true) - @NotBlank(message = "주소는 필수입니다.") - @Size(max = 500, message = "주소는 500자 이하여야 합니다.") - private String address; - - @Schema(description = "매장 전화번호", example = "02-1234-5678", required = true) - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") - private String phoneNumber; - - @Schema(description = "사업자 번호", example = "123-45-67890", required = true) - @NotBlank(message = "사업자 번호는 필수입니다.") - @Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.") - private String businessNumber; - - @Schema(description = "인스타그램 계정", example = "@mycafe") - @Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") - private String instaAccount; - - @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") - @Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") - private String naverBlogAccount; - - @Schema(description = "오픈 시간", example = "09:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String openTime; - - @Schema(description = "마감 시간", example = "22:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String closeTime; - - @Schema(description = "휴무일", example = "매주 월요일") - @Size(max = 100, message = "휴무일은 100자 이하여야 합니다.") - private String closedDays; - - @Schema(description = "좌석 수", example = "20") - private Integer seatCount; -} diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java deleted file mode 100644 index 72898f3..0000000 --- a/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.won.smarketing.store.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -/** - * 매장 정보 응답 DTO - * 매장 정보 조회/등록/수정 시 반환되는 데이터 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "매장 정보 응답") -public class StoreResponse { - - @Schema(description = "매장 ID", example = "1") - private Long storeId; - - @Schema(description = "매장명", example = "맛있는 카페") - private String storeName; - - @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") - private String storeImage; - - @Schema(description = "업종", example = "카페") - private String businessType; - - @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") - private String address; - - @Schema(description = "매장 전화번호", example = "02-1234-5678") - private String phoneNumber; - - @Schema(description = "사업자 번호", example = "123-45-67890") - private String businessNumber; - - @Schema(description = "인스타그램 계정", example = "@mycafe") - private String instaAccount; - - @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") - private String naverBlogAccount; - - @Schema(description = "오픈 시간", example = "09:00") - private String openTime; - - @Schema(description = "마감 시간", example = "22:00") - private String closeTime; - - @Schema(description = "휴무일", example = "매주 월요일") - private String closedDays; - - @Schema(description = "좌석 수", example = "20") - private Integer seatCount; - - @Schema(description = "등록 시각") - private LocalDateTime createdAt; - - @Schema(description = "수정 시각") - private LocalDateTime updatedAt; -} diff --git a/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java deleted file mode 100644 index 7bb9306..0000000 --- a/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.won.smarketing.store.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import javax.validation.constraints.Pattern; -import javax.validation.constraints.Size; - -/** - * 매장 수정 요청 DTO - * 매장 정보 수정 시 필요한 정보를 담는 데이터 전송 객체 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Schema(description = "매장 수정 요청 정보") -public class StoreUpdateRequest { - - @Schema(description = "매장명", example = "맛있는 카페") - @Size(max = 200, message = "매장명은 200자 이하여야 합니다.") - private String storeName; - - @Schema(description = "매장 이미지 URL", example = "https://example.com/store.jpg") - private String storeImage; - - @Schema(description = "매장 주소", example = "서울시 강남구 테헤란로 123") - @Size(max = 500, message = "주소는 500자 이하여야 합니다.") - private String address; - - @Schema(description = "매장 전화번호", example = "02-1234-5678") - @Pattern(regexp = "^\\d{2,3}-\\d{3,4}-\\d{4}$", message = "올바른 전화번호 형식이 아닙니다.") - private String phoneNumber; - - @Schema(description = "인스타그램 계정", example = "@mycafe") - @Size(max = 100, message = "인스타그램 계정은 100자 이하여야 합니다.") - private String instaAccount; - - @Schema(description = "네이버 블로그 계정", example = "mycafe_blog") - @Size(max = 100, message = "네이버 블로그 계정은 100자 이하여야 합니다.") - private String naverBlogAccount; - - @Schema(description = "오픈 시간", example = "09:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String openTime; - - @Schema(description = "마감 시간", example = "22:00") - @Pattern(regexp = "^\\d{2}:\\d{2}$", message = "올바른 시간 형식이 아닙니다. (HH:MM)") - private String closeTime; - - @Schema(description = "휴무일", example = "매주 월요일") - @Size(max = 100, message = "휴무일은 100자 이하여야 합니다.") - private String closedDays; - - @Schema(description = "좌석 수", example = "20") - private Integer seatCount; -} diff --git a/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/store/src/main/java/com/won/smarketing/store/entity/Menu.java deleted file mode 100644 index 7461445..0000000 --- a/store/src/main/java/com/won/smarketing/store/entity/Menu.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.won.smarketing.store.entity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 메뉴 정보를 나타내는 엔티티 - * 메뉴명, 카테고리, 가격, 설명, 이미지 정보 저장 - */ -@Entity -@Table(name = "menus") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Menu { - - /** - * 메뉴 고유 식별자 - */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /** - * 매장 ID - */ - @Column(name = "store_id", nullable = false) - private Long storeId; - - /** - * 메뉴명 - */ - @Column(name = "menu_name", nullable = false, length = 200) - private String menuName; - - /** - * 메뉴 카테고리 - */ - @Column(name = "category", nullable = false, length = 100) - private String category; - - /** - * 가격 - */ - @Column(name = "price", nullable = false) - private Integer price; - - /** - * 메뉴 설명 - */ - @Column(name = "description", columnDefinition = "TEXT") - private String description; - - /** - * 메뉴 이미지 URL - */ - @Column(name = "image", length = 500) - private String image; - - /** - * 메뉴 등록 시각 - */ - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - /** - * 메뉴 정보 수정 시각 - */ - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - /** - * 엔티티 저장 전 실행되는 메서드 - * 생성 시각과 수정 시각을 현재 시각으로 설정 - */ - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - /** - * 엔티티 업데이트 전 실행되는 메서드 - * 수정 시각을 현재 시각으로 갱신 - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - - /** - * 메뉴 정보 업데이트 메서드 - * - * @param menuName 메뉴명 - * @param category 카테고리 - * @param price 가격 - * @param description 설명 - * @param image 이미지 URL - */ - public void updateMenuInfo(String menuName, String category, Integer price, String description, String image) { - this.menuName = menuName; - this.category = category; - this.price = price; - this.description = description; - this.image = image; - } -} diff --git a/store/src/main/java/com/won/smarketing/store/entity/Store.java b/store/src/main/java/com/won/smarketing/store/entity/Store.java deleted file mode 100644 index e2688d8..0000000 --- a/store/src/main/java/com/won/smarketing/store/entity/Store.java +++ /dev/null @@ -1,164 +0,0 @@ -package com.won.smarketing.store.entity; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -/** - * 매장 정보를 나타내는 엔티티 - * 매장의 기본 정보, 운영 정보, SNS 계정 정보 저장 - */ -@Entity -@Table(name = "stores") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@Builder -public class Store { - - /** - * 매장 고유 식별자 - */ - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /** - * 매장 소유자 사용자 ID - */ - @Column(name = "user_id", unique = true, nullable = false, length = 50) - private String userId; - - /** - * 매장명 - */ - @Column(name = "store_name", nullable = false, length = 200) - private String storeName; - - /** - * 매장 이미지 URL - */ - @Column(name = "store_image", length = 500) - private String storeImage; - - /** - * 업종 - */ - @Column(name = "business_type", nullable = false, length = 100) - private String businessType; - - /** - * 매장 주소 - */ - @Column(name = "address", nullable = false, length = 500) - private String address; - - /** - * 매장 전화번호 - */ - @Column(name = "phone_number", nullable = false, length = 20) - private String phoneNumber; - - /** - * 사업자 번호 - */ - @Column(name = "business_number", nullable = false, length = 20) - private String businessNumber; - - /** - * 인스타그램 계정 - */ - @Column(name = "insta_account", length = 100) - private String instaAccount; - - /** - * 네이버 블로그 계정 - */ - @Column(name = "naver_blog_account", length = 100) - private String naverBlogAccount; - - /** - * 오픈 시간 - */ - @Column(name = "open_time", length = 10) - private String openTime; - - /** - * 마감 시간 - */ - @Column(name = "close_time", length = 10) - private String closeTime; - - /** - * 휴무일 - */ - @Column(name = "closed_days", length = 100) - private String closedDays; - - /** - * 좌석 수 - */ - @Column(name = "seat_count") - private Integer seatCount; - - /** - * 매장 등록 시각 - */ - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - /** - * 매장 정보 수정 시각 - */ - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; - - /** - * 엔티티 저장 전 실행되는 메서드 - * 생성 시각과 수정 시각을 현재 시각으로 설정 - */ - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - /** - * 엔티티 업데이트 전 실행되는 메서드 - * 수정 시각을 현재 시각으로 갱신 - */ - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } - - /** - * 매장 정보 업데이트 메서드 - * - * @param storeName 매장명 - * @param storeImage 매장 이미지 - * @param address 주소 - * @param phoneNumber 전화번호 - * @param instaAccount 인스타그램 계정 - * @param naverBlogAccount 네이버 블로그 계정 - * @param openTime 오픈 시간 - * @param closeTime 마감 시간 - * @param closedDays 휴무일 - * @param seatCount 좌석 수 - */ - public void updateStoreInfo(String storeName, String storeImage, String address, String phoneNumber, - String instaAccount, String naverBlogAccount, String openTime, String closeTime, - String closedDays, Integer seatCount) { - this.storeName = storeName; - this.storeImage = storeImage; - this.address = address; - this.phoneNumber = phoneNumber; - this.instaAccount = instaAccount; - this.naverBlogAccount = naverBlogAccount; - this.openTime = openTime; - this.closeTime = closeTime; - this.closedDays = closedDays; - this.seatCount = seatCount; - } -} diff --git a/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java deleted file mode 100644 index c36d1c5..0000000 --- a/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.won.smarketing.store.repository; - -import com.won.smarketing.store.entity.Sales; -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.math.BigDecimal; - -/** - * 매출 정보 데이터 접근을 위한 Repository - * JPA를 사용한 매출 조회 작업 처리 - */ -@Repository -public interface SalesRepository extends JpaRepository { - - /** - * 매장의 오늘 매출 조회 - * - * @param storeId 매장 ID - * @return 오늘 매출 - */ - @Query("SELECT COALESCE(SUM(s.salesAmount), 0) FROM Sales s WHERE s.storeId = :storeId AND s.salesDate = CURRENT_DATE") - BigDecimal findTodaySalesByStoreId(@Param("storeId") Long storeId); - - /** - * 매장의 이번 달 매출 조회 - * - * @param storeId 매장 ID - * @return 이번 달 매출 - */ - @Query("SELECT COALESCE(SUM(s.salesAmount), 0) FROM Sales s WHERE s.storeId = :storeId AND YEAR(s.salesDate) = YEAR(CURRENT_DATE) AND MONTH(s.salesDate) = MONTH(CURRENT_DATE)") - BigDecimal findMonthSalesByStoreId(@Param("storeId") Long storeId); - - /** - * 매장의 전일 대비 매출 변화량 조회 - * - * @param storeId 매장 ID - * @return 전일 대비 매출 변화량 - */ - @Query("SELECT COALESCE((SELECT SUM(s1.salesAmount) FROM Sales s1 WHERE s1.storeId = :storeId AND s1.salesDate = CURRENT_DATE) - (SELECT SUM(s2.salesAmount) FROM Sales s2 WHERE s2.storeId = :storeId AND s2.salesDate = CURRENT_DATE - 1), 0)") - BigDecimal findPreviousDayComparisonByStoreId(@Param("storeId") Long storeId); -} diff --git a/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java deleted file mode 100644 index f4109ab..0000000 --- a/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.won.smarketing.store.service; - -import com.won.smarketing.store.dto.SalesResponse; -import com.won.smarketing.store.repository.SalesRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.math.BigDecimal; - -/** - * 매출 관리 서비스 구현체 - * 매출 조회 기능 구현 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class SalesServiceImpl implements SalesService { - - private final SalesRepository salesRepository; - - /** - * 매출 정보 조회 - * - * @return 매출 정보 (오늘, 월간, 전일 대비) - */ - @Override - public SalesResponse getSales() { - // TODO: 현재는 더미 데이터 반환, 실제로는 현재 로그인한 사용자의 매장 ID를 사용해야 함 - Long storeId = 1L; // 임시로 설정 - - BigDecimal todaySales = salesRepository.findTodaySalesByStoreId(storeId); - BigDecimal monthSales = salesRepository.findMonthSalesByStoreId(storeId); - BigDecimal previousDayComparison = salesRepository.findPreviousDayComparisonByStoreId(storeId); - - return SalesResponse.builder() - .todaySales(todaySales != null ? todaySales : BigDecimal.ZERO) - .monthSales(monthSales != null ? monthSales : BigDecimal.ZERO) - .previousDayComparison(previousDayComparison != null ? previousDayComparison : BigDecimal.ZERO) - .build(); - } -} diff --git a/store/src/main/resources/application.yml b/store/src/main/resources/application.yml deleted file mode 100644 index 800c27d..0000000 --- a/store/src/main/resources/application.yml +++ /dev/null @@ -1,31 +0,0 @@ -server: - port: ${SERVER_PORT:8082} - servlet: - context-path: / - -spring: - application: - name: store-service - datasource: - url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:storedb} - 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 - -springdoc: - swagger-ui: - path: /swagger-ui.html - operations-sorter: method - api-docs: - path: /api-docs - -logging: - level: - com.won.smarketing.store: ${LOG_LEVEL:DEBUG}