This commit is contained in:
unknown 2025-06-11 17:56:59 +09:00
commit 2004d7c736
36 changed files with 1544 additions and 586 deletions

View File

@ -1,6 +0,0 @@
CLAUDE_API_KEY=your_claude_api_key_here
OPENAI_API_KEY=your_openai_api_key_here
FLASK_ENV=development
UPLOAD_FOLDER=uploads
MAX_CONTENT_LENGTH=16777216
SECRET_KEY=your-secret-key-for-production

23
smarketing-ai/.gitignore vendored Normal file
View File

@ -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

24
smarketing-ai/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# 시스템 패키지 설치
RUN apt-get update && apt-get install -y \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 업로드 디렉토리 생성
RUN mkdir -p uploads/temp templates/poster_templates
# 포트 노출
EXPOSE 5000
# 애플리케이션 실행
CMD ["python", "app.py"]

View File

@ -9,22 +9,30 @@ import os
from datetime import datetime
import traceback
from config.config import Config
from services.content_service import ContentService
# from services.content_service import ContentService
from services.poster_service import PosterService
from models.request_models import ContentRequest, PosterRequest
from services.sns_content_service import SnsContentService
# from services.poster_generation_service import PosterGenerationService
from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest
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)
# 서비스 인스턴스 생성
content_service = ContentService()
poster_service = PosterService()
sns_content_service = SnsContentService()
@app.route('/health', methods=['GET'])
def health_check():
"""헬스 체크 API"""
@ -33,16 +41,120 @@ def create_app():
'timestamp': datetime.now().isoformat(),
'service': 'AI Marketing Service'
})
# ===== 새로운 API 엔드포인트 =====
@app.route('/api/ai/sns', methods=['POST'])
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'),
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=['POST'])
def generate_poster_content():
"""
홍보 포스터 생성 API (새로운 요구사항)
Java 서버에서 JSON 형태로 요청받아 OpenAI 이미지 URL 반환
"""
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
# 요청 모델 생성
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'),
eventName=data.get('eventName'),
startDate=data.get('startDate'),
endDate=data.get('endDate')
)
# 포스터 생성
result = poster_service.generate_poster(poster_request)
if result['success']:
return jsonify({'content': result['content']})
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
마케팅 콘텐츠 생성 API (기존)
점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성
"""
try:
# 요청 데이터 검증
if not request.form:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 파일 업로드 처리
uploaded_files = []
if 'images' in request.files:
@ -55,6 +167,7 @@ def create_app():
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', '음식'),
@ -65,15 +178,19 @@ def create_app():
store_name=request.form.get('store_name', ''),
additional_info=request.form.get('additional_info', '')
)
# 콘텐츠 생성
result = content_service.generate_content(content_request)
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:
@ -84,16 +201,18 @@ def create_app():
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
홍보 포스터 생성 API (기존)
점주가 입력한 정보를 바탕으로 시각적 홍보 포스터 생성
"""
try:
# 요청 데이터 검증
if not request.form:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 파일 업로드 처리
uploaded_files = []
if 'images' in request.files:
@ -106,6 +225,7 @@ def create_app():
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', '음식'),
@ -117,15 +237,19 @@ def create_app():
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:
@ -136,15 +260,20 @@ def create_app():
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=5000, debug=True)
app.run(host='0.0.0.0', port=5001, debug=True)

View File

@ -4,24 +4,61 @@ API 요청 데이터 구조를 정의
"""
from dataclasses import dataclass
from typing import List, Optional
@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
eventName: Optional[str] = None
startDate: Optional[str] = None
endDate: Optional[str] = None
@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
eventName: Optional[str] = None
startDate: Optional[str] = None
endDate: Optional[str] = None
# 기존 모델들은 유지
@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 # 추가 정보
"""마케팅 콘텐츠 생성 요청 모델 (기존)"""
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 # 추가 정보
"""홍보 포스터 생성 요청 모델 (기존)"""
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

View File

@ -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

View File

@ -1,200 +0,0 @@
"""
마케팅 콘텐츠 생성 서비스
AI를 활용하여 플랫폼별 맞춤 게시글 생성
"""
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 ContentRequest
class ContentService:
"""마케팅 콘텐츠 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# 플랫폼별 콘텐츠 특성 정의
self.platform_specs = {
'인스타그램': {
'max_length': 2200,
'hashtag_count': 15,
'style': '감성적이고 시각적',
'format': '짧은 문장, 해시태그 활용'
},
'네이버 블로그': {
'max_length': 3000,
'hashtag_count': 10,
'style': '정보성과 친근함',
'format': '구조화된 내용, 상세 설명'
}
}
# 카테고리별 키워드 정의
self.category_keywords = {
'음식': ['맛집', '신메뉴', '추천', '맛있는', '특별한', '인기'],
'매장': ['분위기', '인테리어', '편안한', '아늑한', '특별한', '방문'],
'이벤트': ['할인', '이벤트', '특가', '한정', '기간한정', '혜택']
}
def generate_content(self, request: ContentRequest) -> Dict[str, Any]:
"""
마케팅 콘텐츠 생성
Args:
request: 콘텐츠 생성 요청 데이터
Returns:
생성된 콘텐츠 정보
"""
try:
# 이미지 분석
image_analysis = self._analyze_images(request.image_paths)
# AI 프롬프트 생성
prompt = self._create_content_prompt(request, image_analysis)
# AI로 콘텐츠 생성
generated_content = self.ai_client.generate_text(prompt)
# 해시태그 생성
hashtags = self._generate_hashtags(request)
# 최종 콘텐츠 포맷팅
formatted_content = self._format_content(
generated_content,
hashtags,
request.platform
)
return {
'success': True,
'content': formatted_content,
'platform': request.platform,
'category': request.category,
'generated_at': datetime.now().isoformat(),
'image_count': len(request.image_paths),
'image_analysis': image_analysis
}
except Exception as e:
return {
'success': False,
'error': str(e),
'generated_at': datetime.now().isoformat()
}
def _analyze_images(self, image_paths: list) -> Dict[str, Any]:
"""
업로드된 이미지들 분석
Args:
image_paths: 이미지 파일 경로 리스트
Returns:
이미지 분석 결과
"""
analysis_results = []
for image_path in image_paths:
try:
# 이미지 기본 정보 추출
image_info = self.image_processor.get_image_info(image_path)
# AI를 통한 이미지 내용 분석
image_description = self.ai_client.analyze_image(image_path)
analysis_results.append({
'path': image_path,
'info': image_info,
'description': image_description
})
except Exception as e:
analysis_results.append({
'path': image_path,
'error': str(e)
})
return {
'total_images': len(image_paths),
'results': analysis_results
}
def _create_content_prompt(self, request: ContentRequest, image_analysis: Dict[str, Any]) -> str:
"""
AI 콘텐츠 생성을 위한 프롬프트 생성
Args:
request: 콘텐츠 생성 요청
image_analysis: 이미지 분석 결과
Returns:
AI 프롬프트 문자열
"""
platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램'])
category_keywords = self.category_keywords.get(request.category, [])
# 이미지 설명 추출
image_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
image_descriptions.append(result['description'])
prompt = f"""
당신은 소상공인을 위한 마케팅 콘텐츠 전문가입니다.
다음 정보를 바탕으로 {request.platform} 적합한 {request.category} 카테고리의 게시글을 작성해주세요.
**매장 정보:**
- 매장명: {request.store_name or '우리 가게'}
- 카테고리: {request.category}
- 추가 정보: {request.additional_info or '없음'}
**이벤트 정보:**
- 시작 시간: {request.start_time or '상시'}
- 종료 시간: {request.end_time or '상시'}
**이미지 분석 결과:**
{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'}
**플랫폼 특성:**
- 최대 길이: {platform_spec['max_length']}
- 스타일: {platform_spec['style']}
- 형식: {platform_spec['format']}
**요구사항:**
1. {request.platform} 특성에 맞는 톤앤매너 사용
2. {request.category} 카테고리에 적합한 내용 구성
3. 고객의 관심을 있는 매력적인 문구 사용
4. 이미지와 연관된 내용으로 작성
5. 자연스럽고 친근한 어조 사용
해시태그는 별도로 생성하므로 본문에는 포함하지 마세요.
"""
return prompt
def _generate_hashtags(self, request: ContentRequest) -> list:
"""
카테고리와 플랫폼에 맞는 해시태그 생성
Args:
request: 콘텐츠 생성 요청
Returns:
해시태그 리스트
"""
platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램'])
category_keywords = self.category_keywords.get(request.category, [])
hashtags = []
# 기본 해시태그
if request.store_name:
hashtags.append(f"#{request.store_name.replace(' ', '')}")
# 카테고리별 해시태그
hashtags.extend([f"#{keyword}" for keyword in category_keywords[:5]])
# 공통 해시태그
common_tags = ['#맛집', '#소상공인', '#로컬맛집', '#일상', '#소통']
hashtags.extend(common_tags)
# 플랫폼별 인기 해시태그
if request.platform == '인스타그램':
hashtags.extend(['#인스타푸드', '#데일리', '#오늘뭐먹지', '#맛스타그램'])
elif request.platform == '네이버 블로그':
hashtags.extend(['#블로그', '#후기', '#추천', '#정보'])
# 최대 개수 제한
max_count = platform_spec['hashtag_count']
return hashtags[:max_count]
def _format_content(self, content: str, hashtags: list, platform: str) -> str:
"""
플랫폼에 맞게 콘텐츠 포맷팅
Args:
content: 생성된 콘텐츠
hashtags: 해시태그 리스트
platform: 플랫폼명
Returns:
포맷팅된 최종 콘텐츠
"""
platform_spec = self.platform_specs.get(platform, self.platform_specs['인스타그램'])
# 길이 제한 적용
if len(content) > platform_spec['max_length'] - 100: # 해시태그 공간 확보
content = content[:platform_spec['max_length'] - 100] + '...'
# 플랫폼별 포맷팅
if platform == '인스타그램':
# 인스타그램: 본문 + 해시태그
hashtag_string = ' '.join(hashtags)
formatted = f"{content}\n\n{hashtag_string}"
elif platform == '네이버 블로그':
# 네이버 블로그: 구조화된 형태
hashtag_string = ' '.join(hashtags)
formatted = f"{content}\n\n---\n{hashtag_string}"
else:
# 기본 형태
hashtag_string = ' '.join(hashtags)
formatted = f"{content}\n\n{hashtag_string}"
return formatted

View File

@ -1,304 +1,190 @@
"""
홍보 포스터 생성 서비스
AI와 이미지 처리를 활용한 시각적 마케팅 자료 생성
포스터 생성 서비스
OpenAI를 사용한 이미지 생성 (한글 프롬프트)
"""
import os
import base64
from typing import Dict, Any
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterRequest
from models.request_models import PosterContentGetRequest
class PosterService:
"""홍보 포스터 생성 서비스 클래스"""
"""포스터 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# 포스터 기본 설정
self.poster_config = {
'width': 1080,
'height': 1350, # 인스타그램 세로 비율
'background_color': (255, 255, 255),
'text_color': (50, 50, 50),
'accent_color': (255, 107, 107)
# 포토 스타일별 프롬프트
self.photo_styles = {
'미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용',
'모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃',
'빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감',
'컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러',
'우아한': '우아하고 고급스러운 느낌, 세련된 분위기',
'캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인'
}
# 카테고리별 색상 테마
self.category_themes = {
'음식': {
'primary': (255, 107, 107), # 빨강
'secondary': (255, 206, 84), # 노랑
'background': (255, 248, 240) # 크림
},
'매장': {
'primary': (74, 144, 226), # 파랑
'secondary': (120, 198, 121), # 초록
'background': (248, 251, 255) # 연한 파랑
},
'이벤트': {
'primary': (156, 39, 176), # 보라
'secondary': (255, 193, 7), # 금색
'background': (252, 248, 255) # 연한 보라
}
# 카테고리별 이미지 스타일
self.category_styles = {
'음식': '음식 사진, 먹음직스러운, 맛있어 보이는',
'매장': '레스토랑 인테리어, 아늑한 분위기',
'이벤트': '홍보용 디자인, 눈길을 끄는',
'메뉴': '메뉴 디자인, 정리된 레이아웃',
'할인': '세일 포스터, 할인 디자인'
}
def generate_poster(self, request: PosterRequest) -> Dict[str, Any]:
# 톤앤매너별 디자인 스타일
self.tone_styles = {
'친근한': '따뜻하고 친근한 색감, 부드러운 느낌',
'정중한': '격식 있고 신뢰감 있는 디자인',
'재미있는': '밝고 유쾌한 분위기, 활기찬 색상',
'전문적인': '전문적이고 신뢰할 수 있는 디자인'
}
# 감정 강도별 디자인
self.emotion_designs = {
'약함': '은은하고 차분한 색감, 절제된 표현',
'보통': '적당히 활기찬 색상, 균형잡힌 디자인',
'강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인'
}
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
홍보 포스터 생성
Args:
request: 포스터 생성 요청 데이터
Returns:
생성된 포스터 정보
포스터 생성 (OpenAI 이미지 URL 반환)
"""
try:
# 포스터 텍스트 내용 생성
poster_text = self._generate_poster_text(request)
# 이미지 전처리
processed_images = self._process_images(request.image_paths)
# 포스터 이미지 생성
poster_image = self._create_poster_image(request, poster_text, processed_images)
# 이미지를 base64로 인코딩
poster_base64 = self._encode_image_to_base64(poster_image)
# 참조 이미지 분석 (있는 경우)
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,
'poster_data': poster_base64,
'poster_text': poster_text,
'category': request.category,
'generated_at': datetime.now().isoformat(),
'image_count': len(request.image_paths),
'format': 'base64'
'content': image_url
}
except Exception as e:
return {
'success': False,
'error': str(e),
'generated_at': datetime.now().isoformat()
'error': str(e)
}
def _generate_poster_text(self, request: PosterRequest) -> Dict[str, str]:
def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]:
"""
포스터에 들어갈 텍스트 내용 생성
Args:
request: 포스터 생성 요청
Returns:
포스터 텍스트 구성 요소들
참조 이미지들 분석
"""
# 이미지 분석
image_descriptions = []
for image_path in request.image_paths:
try:
description = self.ai_client.analyze_image(image_path)
image_descriptions.append(description)
except:
continue
# AI 프롬프트 생성
prompt = f"""
당신은 소상공인을 위한 포스터 카피라이터입니다.
다음 정보를 바탕으로 매력적인 포스터 문구를 작성해주세요.
**매장 정보:**
- 매장명: {request.store_name or '우리 가게'}
- 카테고리: {request.category}
- 추가 정보: {request.additional_info or '없음'}
**이벤트 정보:**
- 이벤트 제목: {request.event_title or '특별 이벤트'}
- 할인 정보: {request.discount_info or '특가 진행'}
- 시작 시간: {request.start_time or '상시'}
- 종료 시간: {request.end_time or '상시'}
**이미지 설명:**
{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'}
다음 형식으로 응답해주세요:
1. 메인 헤드라인 (10글자 이내, 임팩트 있게)
2. 서브 헤드라인 (20글자 이내, 구체적 혜택)
3. 설명 문구 (30글자 이내, 친근하고 매력적으로)
4. 행동 유도 문구 (15글자 이내, 액션 유도)
항목은 줄바꿈으로 구분해서 작성해주세요.
"""
# AI로 텍스트 생성
generated_text = self.ai_client.generate_text(prompt)
# 생성된 텍스트 파싱
lines = generated_text.strip().split('\n')
return {
'main_headline': lines[0] if len(lines) > 0 else request.event_title or '특별 이벤트',
'sub_headline': lines[1] if len(lines) > 1 else request.discount_info or '지금 바로!',
'description': lines[2] if len(lines) > 2 else '특별한 혜택을 놓치지 마세요',
'call_to_action': lines[3] if len(lines) > 3 else '지금 방문하세요!'
}
def _process_images(self, image_paths: list) -> list:
"""
포스터에 사용할 이미지들 전처리
Args:
image_paths: 원본 이미지 경로 리스트
Returns:
전처리된 이미지 객체 리스트
"""
processed_images = []
for image_path in image_paths:
try:
# 이미지 로드 및 리사이즈
image = Image.open(image_path)
# RGBA로 변환 (투명도 처리)
if image.mode != 'RGBA':
image = image.convert('RGBA')
# 포스터에 맞게 리사이즈 (최대 400x400)
image.thumbnail((400, 400), Image.Resampling.LANCZOS)
processed_images.append(image)
except Exception as e:
print(f"이미지 처리 오류 {image_path}: {e}")
continue
return processed_images
def _create_poster_image(self, request: PosterRequest, poster_text: Dict[str, str], images: list) -> Image.Image:
"""
실제 포스터 이미지 생성
Args:
request: 포스터 생성 요청
poster_text: 포스터 텍스트
images: 전처리된 이미지 리스트
Returns:
생성된 포스터 이미지
"""
# 카테고리별 테마 적용
theme = self.category_themes.get(request.category, self.category_themes['음식'])
# 캔버스 생성
poster = Image.new('RGBA',
(self.poster_config['width'], self.poster_config['height']),
theme['background'])
draw = ImageDraw.Draw(poster)
# 폰트 설정 (시스템 기본 폰트 사용)
if not image_urls:
return {'total_images': 0, 'results': []}
analysis_results = []
temp_files = []
try:
# 다양한 폰트 시도
title_font = ImageFont.truetype("arial.ttf", 60)
subtitle_font = ImageFont.truetype("arial.ttf", 40)
text_font = ImageFont.truetype("arial.ttf", 30)
small_font = ImageFont.truetype("arial.ttf", 24)
except:
# 기본 폰트 사용
title_font = ImageFont.load_default()
subtitle_font = ImageFont.load_default()
text_font = ImageFont.load_default()
small_font = ImageFont.load_default()
# 레이아웃 계산
y_pos = 80
# 1. 메인 헤드라인
main_headline = poster_text['main_headline']
bbox = draw.textbbox((0, 0), main_headline, font=title_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), main_headline,
fill=theme['primary'], font=title_font)
y_pos += 100
# 2. 서브 헤드라인
sub_headline = poster_text['sub_headline']
bbox = draw.textbbox((0, 0), sub_headline, font=subtitle_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), sub_headline,
fill=theme['secondary'], font=subtitle_font)
y_pos += 80
# 3. 이미지 배치 (있는 경우)
if images:
image_y = y_pos + 30
if len(images) == 1:
# 단일 이미지: 중앙 배치
img = images[0]
img_x = (self.poster_config['width'] - img.width) // 2
poster.paste(img, (img_x, image_y), img)
y_pos = image_y + img.height + 50
elif len(images) == 2:
# 두 개 이미지: 나란히 배치
total_width = sum(img.width for img in images) + 20
start_x = (self.poster_config['width'] - total_width) // 2
for i, img in enumerate(images):
img_x = start_x + (i * (img.width + 20))
poster.paste(img, (img_x, image_y), img)
y_pos = image_y + max(img.height for img in images) + 50
else:
# 여러 이미지: 그리드 형태
cols = 2
rows = (len(images) + cols - 1) // cols
img_spacing = 20
for i, img in enumerate(images[:4]): # 최대 4개
row = i // cols
col = i % cols
img_x = (self.poster_config['width'] // cols) * col + \
(self.poster_config['width'] // cols - img.width) // 2
img_y = image_y + row * (200 + img_spacing)
poster.paste(img, (img_x, img_y), img)
y_pos = image_y + rows * (200 + img_spacing) + 30
# 4. 설명 문구
description = poster_text['description']
# 긴 텍스트는 줄바꿈 처리
words = description.split()
lines = []
current_line = []
for word in words:
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=text_font)
if bbox[2] - bbox[0] < self.poster_config['width'] - 100:
current_line.append(word)
else:
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
if current_line:
lines.append(' '.join(current_line))
for line in lines:
bbox = draw.textbbox((0, 0), line, font=text_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=text_font)
y_pos += 40
y_pos += 30
# 5. 기간 정보 (있는 경우)
if request.start_time and request.end_time:
period_text = f"기간: {request.start_time} ~ {request.end_time}"
bbox = draw.textbbox((0, 0), period_text, font=small_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
draw.text((x_pos, y_pos), period_text, fill=(120, 120, 120), font=small_font)
y_pos += 50
# 6. 행동 유도 문구 (버튼 스타일)
cta_text = poster_text['call_to_action']
bbox = draw.textbbox((0, 0), cta_text, font=subtitle_font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 버튼 배경
button_width = text_width + 60
button_height = text_height + 30
button_x = (self.poster_config['width'] - button_width) // 2
button_y = self.poster_config['height'] - 150
draw.rounded_rectangle([button_x, button_y, button_x + button_width, button_y + button_height],
radius=25, fill=theme['primary'])
# 버튼 텍스트
text_x = button_x + (button_width - text_width) // 2
text_y = button_y + (button_height - text_height) // 2
draw.text((text_x, text_y), cta_text, fill=(255, 255, 255), font=subtitle_font)
# 7. 매장명 (하단)
if request.store_name:
store_text = request.store_name
bbox = draw.textbbox((0, 0), store_text, font=text_font)
text_width = bbox[2] - bbox[0]
x_pos = (self.poster_config['width'] - text_width) // 2
y_pos = self.poster_config['height'] - 50
draw.text((x_pos, y_pos), store_text, fill=(100, 100, 100), font=text_font)
return poster
def _encode_image_to_base64(self, image: Image.Image) -> str:
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:
"""
PIL 이미지를 base64 문자열로 인코딩
Args:
image: PIL 이미지 객체
Returns:
base64 인코딩된 이미지 문자열
포스터 생성을 위한 AI 프롬프트 생성 (한글)
"""
import io
# RGB로 변환 (JPEG 저장을 위해)
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=90)
img_buffer.seek(0)
# base64 인코딩
img_base64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
return f"data:image/jpeg;base64,{img_base64}"
# 기본 스타일 설정
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.eventName or '특별 프로모션'}
- 시작일: {request.startDate or '지금'}
- 종료일: {request.endDate or '한정 기간'}
**특별 요구사항:**
{request.requirement or '눈길을 끄는 전문적인 디자인'}
**참조 이미지 설명:**
{chr(10).join(reference_descriptions) if reference_descriptions else '참조 이미지 없음'}
{color_info}
**디자인 가이드라인:**
- 한국 음식점/카페에 적합한 깔끔하고 현대적인 레이아웃
- 한글 텍스트 요소를 자연스럽게 포함
- 가독성이 좋은 전문적인 타이포그래피
- 명확한 대비로 읽기 쉽게 구성
- 소셜미디어 공유에 적합한 크기
- 저작권이 없는 오리지널 디자인
- 음식점에 어울리는 맛있어 보이는 색상 조합
- 고객의 시선을 끄는 매력적인 비주얼
고객들이 음식점을 방문하고 싶게 만드는 시각적으로 매력적인 포스터를 만들어주세요.
텍스트는 한글로, 전체적인 분위기는 한국적 감성에 맞게 디자인해주세요.
"""
return prompt

View File

@ -0,0 +1,218 @@
"""
SNS 콘텐츠 생성 서비스
"""
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 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': '짧은 문장, 해시태그 활용'
},
'네이버 블로그': {
'max_length': 3000,
'hashtag_count': 10,
'style': '정보성과 친근함',
'format': '구조화된 내용, 상세 설명'
}
}
# 톤앤매너별 스타일
self.tone_styles = {
'친근한': '반말, 이모티콘 활용, 편안한 어조',
'정중한': '존댓말, 격식 있는 표현, 신뢰감 있는 어조',
'재미있는': '유머 섞인 표현, 트렌디한 말투, 참신한 비유',
'전문적인': '전문 용어 활용, 체계적 설명, 신뢰성 강조'
}
# 감정 강도별 표현
self.emotion_levels = {
'약함': '은은하고 차분한 표현',
'보통': '적당히 활기찬 표현',
'강함': '매우 열정적이고 강렬한 표현'
}
def generate_sns_content(self, request: SnsContentGetRequest) -> Dict[str, Any]:
"""
SNS 콘텐츠 생성 (HTML 형식 반환)
"""
try:
# 이미지 다운로드 및 분석
image_analysis = self._analyze_images_from_urls(request.images)
# AI 프롬프트 생성
prompt = self._create_sns_prompt(request, image_analysis)
# AI로 콘텐츠 생성
generated_content = self.ai_client.generate_text(prompt)
# HTML 형식으로 포맷팅
html_content = self._format_to_html(generated_content, request)
return {
'success': True,
'content': html_content
}
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 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_info = self.image_processor.get_image_info(temp_path)
image_description = self.ai_client.analyze_image(temp_path)
analysis_results.append({
'url': image_url,
'info': image_info,
'description': image_description
})
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_sns_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any]) -> str:
"""
SNS 콘텐츠 생성을 위한 AI 프롬프트 생성
"""
platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램'])
tone_style = self.tone_styles.get(request.toneAndManner, '친근한 어조')
emotion_level = self.emotion_levels.get(request.emotionIntensity, '적당한 강도')
# 이미지 설명 추출
image_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
image_descriptions.append(result['description'])
prompt = f"""
당신은 소상공인을 위한 SNS 마케팅 콘텐츠 전문가입니다.
다음 정보를 바탕으로 {request.platform} 적합한 게시글을 작성해주세요.
**게시물 정보:**
- 제목: {request.title}
- 카테고리: {request.category}
- 콘텐츠 타입: {request.contentType}
**스타일 요구사항:**
- 톤앤매너: {request.toneAndManner} ({tone_style})
- 감정 강도: {request.emotionIntensity} ({emotion_level})
- 특별 요구사항: {request.requirement or '없음'}
**이벤트 정보:**
- 이벤트명: {request.eventName or '없음'}
- 시작일: {request.startDate or '없음'}
- 종료일: {request.endDate or '없음'}
**이미지 분석 결과:**
{chr(10).join(image_descriptions) if image_descriptions else '이미지 없음'}
**플랫폼 특성:**
- 최대 길이: {platform_spec['max_length']}
- 스타일: {platform_spec['style']}
- 형식: {platform_spec['format']}
**요구사항:**
1. {request.platform} 특성에 맞는 톤앤매너 사용
2. {request.category} 카테고리에 적합한 내용 구성
3. 고객의 관심을 있는 매력적인 문구 사용
4. 이미지와 연관된 내용으로 작성
5. 지정된 톤앤매너와 감정 강도에 맞게 작성
본문과 해시태그를 모두 포함하여 완성된 게시글을 작성해주세요.
"""
return prompt
def _format_to_html(self, content: str, request: SnsContentGetRequest) -> str:
"""
생성된 콘텐츠를 HTML 형식으로 포맷팅
"""
# 줄바꿈을 <br> 태그로 변환
content = content.replace('\n', '<br>')
# 해시태그를 파란색으로 스타일링
import re
content = re.sub(r'(#[\w가-힣]+)', r'<span style="color: #1DA1F2; font-weight: bold;">\1</span>', content)
# 이모티콘은 그대로 유지
# 전체 HTML 구조
html_content = f"""
<div style="font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6; padding: 20px; max-width: 600px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px; border-radius: 10px 10px 0 0; text-align: center;">
<h3 style="margin: 0; font-size: 18px;">{request.platform} 게시물</h3>
</div>
<div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; border: 1px solid #e1e8ed;">
<div style="font-size: 16px; color: #333;">
{content}
</div>
{self._add_metadata_html(request)}
</div>
</div>
"""
return html_content
def _add_metadata_html(self, request: SnsContentGetRequest) -> str:
"""
메타데이터를 HTML에 추가
"""
metadata_html = '<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #e1e8ed; font-size: 12px; color: #666;">'
if request.eventName:
metadata_html += f'<div><strong>이벤트:</strong> {request.eventName}</div>'
if request.startDate and request.endDate:
metadata_html += f'<div><strong>기간:</strong> {request.startDate} ~ {request.endDate}</div>'
metadata_html += f'<div><strong>카테고리:</strong> {request.category}</div>'
metadata_html += f'<div><strong>생성일:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M")}</div>'
metadata_html += '</div>'
return metadata_html

View File

@ -4,36 +4,96 @@ Claude AI 및 OpenAI API 호출을 담당
"""
import os
import base64
from typing import Optional
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:
openai.api_key = self.openai_api_key
self.openai_client = openai
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=size,
quality="standard",
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 사용)
Args:
prompt: 생성할 텍스트의 프롬프트
max_tokens: 최대 토큰
Returns:
생성된 텍스트
"""
# Claude AI 시도
if self.claude_client:
@ -48,6 +108,7 @@ class AIClient:
return response.content[0].text
except Exception as e:
print(f"Claude AI 호출 실패: {e}")
# OpenAI 시도
if self.openai_client:
try:
@ -61,19 +122,18 @@ class AIClient:
return response.choices[0].message.content
except Exception as e:
print(f"OpenAI 호출 실패: {e}")
# 기본 응답 (AI 서비스 모두 실패시)
# 기본 응답
return self._generate_fallback_content(prompt)
def analyze_image(self, image_path: str) -> str:
"""
이미지 분석 설명 생성
Args:
image_path: 분석할 이미지 경로
Returns:
이미지 설명 텍스트
"""
try:
# 이미지를 base64로 인코딩
image_base64 = self._encode_image_to_base64(image_path)
# Claude Vision API 시도
if self.claude_client:
try:
@ -103,6 +163,7 @@ class AIClient:
return response.content[0].text
except Exception as e:
print(f"Claude 이미지 분석 실패: {e}")
# OpenAI Vision API 시도
if self.openai_client:
try:
@ -130,47 +191,37 @@ class AIClient:
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로 인코딩
Args:
image_path: 이미지 파일 경로
Returns:
base64 인코딩된 이미지 문자열
"""
"""이미지 파일을 base64로 인코딩"""
with open(image_path, "rb") as image_file:
# 이미지 크기 조정 (API 제한 고려)
image = Image.open(image_file)
# 최대 크기 제한 (1024x1024)
if image.width > 1024 or image.height > 1024:
image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
# JPEG로 변환하여 파일 크기 줄이기
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 서비스 실패시 기본 콘텐츠 생성
Args:
prompt: 원본 프롬프트
Returns:
기본 콘텐츠
"""
"""AI 서비스 실패시 기본 콘텐츠 생성"""
if "콘텐츠" in prompt or "게시글" in prompt:
return """안녕하세요! 오늘도 맛있는 하루 되세요 😊
우리 가게의 특별한 메뉴를 소개합니다!
정성껏 준비한 음식으로 여러분을 맞이하겠습니다.
많은 관심과 사랑 부탁드려요!"""
우리 가게의 특별한 메뉴를 소개합니다!
정성껏 준비한 음식으로 여러분을 맞이하겠습니다.
많은 관심과 사랑 부탁드려요!"""
elif "포스터" in prompt:
return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!"
else:
return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."
return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."

View File

@ -1,4 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
runtimeOnly 'org.postgresql:postgresql'
}

View File

@ -9,9 +9,16 @@ 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.domain.model"
})
public class MarketingContentServiceApplication {
public static void main(String[] args) {

View File

@ -40,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();
@ -105,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())
@ -140,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())
@ -161,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();
}

View File

@ -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();
}

View File

@ -39,7 +39,7 @@ public class SnsContentService implements SnsContentUseCase {
*/
@Override
@Transactional
/* public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
// AI를 사용하여 SNS 콘텐츠 생성
String generatedContent = aiContentGenerator.generateSnsContent(request);
@ -80,11 +80,11 @@ 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();
}*/
}
/**
* SNS 콘텐츠 저장

View File

@ -0,0 +1,18 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/JpaConfig.java
package com.won.smarketing.content.config;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정 클래스
*
* @author smarketing-team
* @version 1.0
*/
@Configuration
@EntityScan(basePackages = "com.won.smarketing.content.infrastructure.entity")
@EnableJpaRepositories(basePackages = "com.won.smarketing.content.infrastructure.repository")
public class JpaConfig {
}

View File

@ -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;
}
}

View File

@ -131,6 +131,9 @@ public class Content {
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
}
// ==================== 비즈니스 로직 메서드 ====================
/**
@ -216,6 +219,24 @@ public class Content {
this.promotionEndDate = endDate;
}
/**
* 홍보 기간 설정
*
* 비즈니스 규칙:
* - 시작일은 종료일보다 이전이어야
* - 과거 날짜로 설정 불가 (현재 시간 기준)
*
* @param startDate 홍보 시작일
* @param endDate 홍보 종료일
* @throws IllegalArgumentException 날짜가 유효하지 않은 경우
*/
public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) {
validatePromotionPeriod(startDate, endDate);
this.promotionStartDate = startDate;
this.promotionEndDate = endDate;
}
/**
* 해시태그 추가
*

View File

@ -1,16 +1,13 @@
package com.won.smarketing.content.domain.model;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;
/**
* 콘텐츠 식별자 객체
* 콘텐츠의 고유 식별자를 나타내는 도메인 객체
*/
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode

View File

@ -53,4 +53,14 @@ public class CreationConditions {
* 사진 스타일 (포스터용)
*/
private String photoStyle;
/**
* 타겟 고객
*/
private String targetAudience;
/**
* 프로모션 타입
*/
private String promotionType;
}

View File

@ -4,6 +4,7 @@ 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 org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@ -12,6 +13,7 @@ import java.util.Optional;
* 콘텐츠 저장소 인터페이스
* 콘텐츠 도메인의 데이터 접근 추상화
*/
@Repository
public interface ContentRepository {
/**

View File

@ -0,0 +1,60 @@
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
/**
* 콘텐츠 조건 JPA 엔티티
*
* @author smarketing-team
* @version 1.0
*/
@Entity
@Table(name = "contents_conditions")
@Getter
@Setter
@NoArgsConstructor
public class ContentConditionsJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne
@JoinColumn(name = "content_id")
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 = 100)
private String emotionIntensity;
@Column(name = "event_name", length = 200)
private String eventName;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "end_date")
private LocalDate endDate;
@Column(name = "photo_style", length = 100)
private String photoStyle;
@Column(name = "TargetAudience", length = 100)
private String targetAudience;
@Column(name = "PromotionType", length = 100)
private String PromotionType;
}

View File

@ -0,0 +1,66 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java
package com.won.smarketing.content.infrastructure.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.List;
/**
* 콘텐츠 JPA 엔티티
*
* @author smarketing-team
* @version 1.0
*/
@Entity
@Table(name = "contents")
@Getter
@Setter
@NoArgsConstructor
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 = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "hashtags", columnDefinition = "JSON")
private String hashtags;
@Column(name = "images", columnDefinition = "JSON")
private String images;
@Column(name = "status", length = 50)
private String status;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// 연관 엔티티
@OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private ContentConditionsJpaEntity conditions;
}

View File

@ -0,0 +1,144 @@
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;
/**
* 콘텐츠 도메인-엔티티 매퍼
*
* @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().name());
entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null);
entity.setTitle(content.getTitle());
entity.setContent(content.getContent());
entity.setHashtags(convertListToJson(content.getHashtags()));
entity.setImages(convertListToJson(content.getImages()));
entity.setStatus(content.getStatus().name());
entity.setCreatedAt(content.getCreatedAt());
entity.setUpdatedAt(content.getUpdatedAt());
// 조건 정보 매핑
if (content.getCreationConditions() != null) {
ContentConditionsJpaEntity conditionsEntity = new ContentConditionsJpaEntity();
conditionsEntity.setContent(entity);
conditionsEntity.setCategory(content.getCreationConditions().getCategory());
conditionsEntity.setRequirement(content.getCreationConditions().getRequirement());
conditionsEntity.setToneAndManner(content.getCreationConditions().getToneAndManner());
conditionsEntity.setEmotionIntensity(content.getCreationConditions().getEmotionIntensity());
conditionsEntity.setEventName(content.getCreationConditions().getEventName());
conditionsEntity.setStartDate(content.getCreationConditions().getStartDate());
conditionsEntity.setEndDate(content.getCreationConditions().getEndDate());
conditionsEntity.setPhotoStyle(content.getCreationConditions().getPhotoStyle());
entity.setConditions(conditionsEntity);
}
return entity;
}
/**
* JPA 엔티티를 도메인 모델로 변환합니다.
*
* @param entity JPA 엔티티
* @return 도메인 콘텐츠
*/
public Content toDomain(ContentJpaEntity entity) {
if (entity == null) {
return null;
}
CreationConditions conditions = null;
if (entity.getConditions() != null) {
conditions = new CreationConditions(
entity.getConditions().getCategory(),
entity.getConditions().getRequirement(),
entity.getConditions().getToneAndManner(),
entity.getConditions().getEmotionIntensity(),
entity.getConditions().getEventName(),
entity.getConditions().getStartDate(),
entity.getConditions().getEndDate(),
entity.getConditions().getPhotoStyle(),
entity.getConditions().getTargetAudience(),
entity.getConditions().getPromotionType()
);
}
return new Content(
ContentId.of(entity.getId()),
ContentType.valueOf(entity.getContentType()),
entity.getPlatform() != null ? Platform.valueOf(entity.getPlatform()) : null,
entity.getTitle(),
entity.getContent(),
convertJsonToList(entity.getHashtags()),
convertJsonToList(entity.getImages()),
ContentStatus.valueOf(entity.getStatus()),
conditions,
entity.getStoreId(),
entity.getCreatedAt(),
entity.getUpdatedAt()
);
}
/**
* List를 JSON 문자열로 변환합니다.
*/
private String convertListToJson(List<String> 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<String> convertJsonToList(String json) {
if (json == null || json.trim().isEmpty()) {
return Collections.emptyList();
}
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception e) {
log.warn("Failed to convert JSON to list: {}", e.getMessage());
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,111 @@
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 기반 콘텐츠 Repository 구현체
*
* @author smarketing-team
* @version 1.0
*/
@Repository
@RequiredArgsConstructor
@Slf4j
public class JpaContentRepository implements ContentRepository {
private final SpringDataContentRepository springDataContentRepository;
private final ContentMapper contentMapper;
/**
* 콘텐츠를 저장합니다.
*
* @param content 저장할 콘텐츠
* @return 저장된 콘텐츠
*/
@Override
public Content save(Content content) {
log.debug("Saving content: {}", content.getId());
ContentJpaEntity entity = contentMapper.toEntity(content);
ContentJpaEntity savedEntity = springDataContentRepository.save(entity);
return contentMapper.toDomain(savedEntity);
}
/**
* ID로 콘텐츠를 조회합니다.
*
* @param id 콘텐츠 ID
* @return 조회된 콘텐츠
*/
@Override
public Optional<Content> findById(ContentId id) {
log.debug("Finding content by id: {}", id.getValue());
return springDataContentRepository.findById(id.getValue())
.map(contentMapper::toDomain);
}
/**
* 필터 조건으로 콘텐츠 목록을 조회합니다.
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
@Override
public List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy) {
log.debug("Finding contents by filters - type: {}, platform: {}, period: {}, sortBy: {}",
contentType, platform, period, sortBy);
List<ContentJpaEntity> entities = springDataContentRepository.findByFilters(
contentType != null ? contentType.name() : null,
platform != null ? platform.name() : null,
period,
sortBy
);
return entities.stream()
.map(contentMapper::toDomain)
.collect(Collectors.toList());
}
/**
* 진행 중인 콘텐츠 목록을 조회합니다.
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
@Override
public List<Content> findOngoingContents(String period) {
log.debug("Finding ongoing contents for period: {}", period);
List<ContentJpaEntity> entities = springDataContentRepository.findOngoingContents(period);
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());
springDataContentRepository.deleteById(id.getValue());
}
}

View File

@ -0,0 +1,51 @@
package com.won.smarketing.content.infrastructure.repository;
import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA 콘텐츠 Repository
*
* @author smarketing-team
* @version 1.0
*/
@Repository
public interface SpringDataContentRepository extends JpaRepository<ContentJpaEntity, Long> {
/**
* 필터 조건으로 콘텐츠를 조회합니다.
*
* @param contentType 콘텐츠 타입
* @param platform 플랫폼
* @param period 기간
* @param sortBy 정렬 기준
* @return 콘텐츠 목록
*/
@Query("SELECT c FROM ContentJpaEntity c WHERE " +
"(:contentType IS NULL OR c.contentType = :contentType) AND " +
"(:platform IS NULL OR c.platform = :platform) AND " +
"(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY) " +
"ORDER BY " +
"CASE WHEN :sortBy = 'latest' THEN c.createdAt END DESC, " +
"CASE WHEN :sortBy = 'oldest' THEN c.createdAt END ASC")
List<ContentJpaEntity> findByFilters(@Param("contentType") String contentType,
@Param("platform") String platform,
@Param("period") String period,
@Param("sortBy") String sortBy);
/**
* 진행 중인 콘텐츠를 조회합니다.
*
* @param period 기간
* @return 진행 중인 콘텐츠 목록
*/
@Query("SELECT c FROM ContentJpaEntity c " +
"WHERE c.status = 'PUBLISHED' AND " +
"(:period IS NULL OR DATE(c.createdAt) >= CURRENT_DATE - INTERVAL :period DAY)")
List<ContentJpaEntity> findOngoingContents(@Param("period") String period);
}

View File

@ -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;
/**

View File

@ -98,6 +98,9 @@ public class ContentResponse {
@Schema(description = "해시태그 개수", example = "8")
private Integer hashtagCount;
@Schema(description = "조회수", example = "8")
private Integer viewCount;
// ==================== 비즈니스 메서드 ====================
/**
@ -227,7 +230,7 @@ public class ContentResponse {
*/
public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) {
ContentResponseBuilder builder = ContentResponse.builder()
.contentId(content.getId().getValue())
.contentId(content.getId())
.contentType(content.getContentType().name())
.platform(content.getPlatform().name())
.title(content.getTitle())

View File

@ -1,3 +1,4 @@
// 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;
@ -8,6 +9,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@ -20,6 +22,13 @@ import java.util.List;
@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;
@ -48,4 +57,23 @@ public class PosterContentCreateRequest {
@NotNull(message = "이미지는 1개 이상 필수입니다")
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
private List<String> 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;
}

View File

@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 포스터 콘텐츠 생성 응답 DTO
@ -27,8 +28,11 @@ public class PosterContentCreateResponse {
@Schema(description = "생성된 포스터 텍스트 내용")
private String content;
@Schema(description = "포스터 이미지 URL 목록")
private List<String> posterImages;
@Schema(description = "생성된 포스터 타입")
private String contentType;
@Schema(description = "포스터 이미지 URL")
private String posterImage;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@ -38,4 +42,8 @@ public class PosterContentCreateResponse {
@Schema(description = "생성 상태", example = "DRAFT")
private String status;
@Schema(description = "포스터사이즈", example = "800x600")
private Map<String, String> posterSizes;
}

View File

@ -1,3 +1,4 @@
// 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;
@ -6,6 +7,9 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/**
* 포스터 콘텐츠 저장 요청 DTO
*/
@ -19,15 +23,44 @@ public class PosterContentSaveRequest {
@NotNull(message = "콘텐츠 ID는 필수입니다")
private Long contentId;
@Schema(description = "최종 제목", example = "특별 이벤트 안내")
private String finalTitle;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
private Long storeId;
@Schema(description = "최종 콘텐츠 내용")
private String finalContent;
@Schema(description = "제목", example = "특별 이벤트 안내")
private String title;
@Schema(description = "콘텐츠 내용")
private String content;
@Schema(description = "선택된 포스터 이미지 URL")
private String selectedPosterImage;
private List<String> 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;
}

View File

@ -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<String> 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;
}
}

View File

@ -93,6 +93,9 @@ public class SnsContentCreateResponse {
@Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개")
private String category;
@Schema(description = "보정된 이미지 URL 목록")
private List<String> fixedImages;
// ==================== 편집 가능 여부 ====================
@Schema(description = "제목 편집 가능 여부", example = "true")

View File

@ -1,3 +1,4 @@
// 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;
@ -8,6 +9,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@ -24,6 +26,26 @@ public class SnsContentSaveRequest {
@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<String> hashtags;
@Schema(description = "이미지 URL 목록")
private List<String> images;
@Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!")
private String finalTitle;
@ -32,4 +54,26 @@ public class SnsContentSaveRequest {
@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;
}

View File

@ -1,15 +1,14 @@
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}
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: ${JPA_DDL_AUTO:update}
@ -29,14 +28,10 @@ external:
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
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
logging:
level:
com.won.smarketing.content: ${LOG_LEVEL:DEBUG}

View File

@ -1,4 +1,5 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
//
runtimeOnly 'org.postgresql:postgresql'
}