'
+
+ 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