mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2025-12-06 07:06:24 +00:00
release
This commit is contained in:
commit
44d7312a85
109
.idea/workspace.xml
generated
Normal file
109
.idea/workspace.xml
generated
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="7d9c48b3-e5c8-4a1c-af9a-469e24fa5715" name="변경" comment="">
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/.gitignore" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/gradle.xml" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/.idea/vcs.xml" beforeDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="ExternalProjectsData">
|
||||||
|
<projectState path="$PROJECT_DIR$">
|
||||||
|
<ProjectState />
|
||||||
|
</projectState>
|
||||||
|
</component>
|
||||||
|
<component name="ExternalProjectsManager">
|
||||||
|
<system id="GRADLE">
|
||||||
|
<state>
|
||||||
|
<task path="$PROJECT_DIR$">
|
||||||
|
<activation />
|
||||||
|
</task>
|
||||||
|
<projects_view />
|
||||||
|
</state>
|
||||||
|
</system>
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"customColor": "",
|
||||||
|
"associatedIndex": 4
|
||||||
|
}</component>
|
||||||
|
<component name="ProjectId" id="2yLeuaqHXgKgtNCa4XzAZzifagS" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
|
"keyToString": {
|
||||||
|
"Gradle.member.executor": "Run",
|
||||||
|
"Gradle.소스 다운로드.executor": "Run",
|
||||||
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"git-widget-placeholder": "main",
|
||||||
|
"last_opened_file_path": "C:/home/workspace/smarketing/smarketing-backend",
|
||||||
|
"project.structure.last.edited": "SDK",
|
||||||
|
"project.structure.proportion": "0.15",
|
||||||
|
"project.structure.side.proportion": "0.2",
|
||||||
|
"settings.editor.selected.configurable": "reference.settingsdialog.project.gradle"
|
||||||
|
}
|
||||||
|
}]]></component>
|
||||||
|
<component name="RunDashboard">
|
||||||
|
<option name="configurationTypes">
|
||||||
|
<set>
|
||||||
|
<option value="GradleRunConfiguration" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="RunManager">
|
||||||
|
<configuration name="member" type="GradleRunConfiguration" factoryName="Gradle">
|
||||||
|
<ExternalSystemSettings>
|
||||||
|
<option name="env">
|
||||||
|
<map>
|
||||||
|
<entry key="POSTGRES_HOST" value="psql-digitalgarage-02.postgres.database.azure.com" />
|
||||||
|
<entry key="POSTGRES_PASSWORD" value="DG_Won!" />
|
||||||
|
<entry key="POSTGRES_USER" value="pgadmin" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
<option name="executionName" />
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
|
<option name="passParentEnvs" value="false" />
|
||||||
|
<option name="scriptParameters" value="" />
|
||||||
|
<option name="taskDescriptions">
|
||||||
|
<list />
|
||||||
|
</option>
|
||||||
|
<option name="taskNames">
|
||||||
|
<list>
|
||||||
|
<option value=":member:bootRun" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="vmOptions" />
|
||||||
|
</ExternalSystemSettings>
|
||||||
|
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
|
||||||
|
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
|
||||||
|
<DebugAllEnabled>false</DebugAllEnabled>
|
||||||
|
<RunAsTest>false</RunAsTest>
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="디폴트 작업">
|
||||||
|
<changelist id="7d9c48b3-e5c8-4a1c-af9a-469e24fa5715" name="변경" comment="" />
|
||||||
|
<created>1749618504890</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1749618504890</updated>
|
||||||
|
</task>
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
23
smarketing-ai/.gitignore
vendored
Normal file
23
smarketing-ai/.gitignore
vendored
Normal 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
|
||||||
131
smarketing-ai/api/marketing_tip_api.py
Normal file
131
smarketing-ai/api/marketing_tip_api.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
마케팅 팁 생성 API 엔드포인트
|
||||||
|
Java 서비스와 연동되는 API
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from datetime import datetime
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from services.marketing_tip_service import MarketingTipService
|
||||||
|
from models.marketing_tip_models import MarketingTipGenerateRequest, MarketingTipResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Blueprint 생성
|
||||||
|
marketing_tip_bp = Blueprint('marketing_tip', __name__)
|
||||||
|
|
||||||
|
# 서비스 인스턴스
|
||||||
|
marketing_tip_service = MarketingTipService()
|
||||||
|
|
||||||
|
|
||||||
|
@marketing_tip_bp.route('/api/v1/generate-marketing-tip', methods=['POST'])
|
||||||
|
def generate_marketing_tip():
|
||||||
|
"""
|
||||||
|
AI 마케팅 팁 생성 API
|
||||||
|
Java 서비스에서 호출하는 엔드포인트
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 요청 데이터 검증
|
||||||
|
if not request.is_json:
|
||||||
|
return jsonify({
|
||||||
|
'tip': '',
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Content-Type이 application/json이어야 합니다.',
|
||||||
|
'generated_at': '',
|
||||||
|
'store_name': '',
|
||||||
|
'business_type': '',
|
||||||
|
'ai_model': ''
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({
|
||||||
|
'tip': '',
|
||||||
|
'status': 'error',
|
||||||
|
'message': '요청 데이터가 없습니다.',
|
||||||
|
'generated_at': '',
|
||||||
|
'store_name': '',
|
||||||
|
'business_type': '',
|
||||||
|
'ai_model': ''
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 필수 필드 검증
|
||||||
|
if 'store_name' not in data or not data['store_name']:
|
||||||
|
return jsonify({
|
||||||
|
'tip': '',
|
||||||
|
'status': 'error',
|
||||||
|
'message': '매장명(store_name)은 필수입니다.',
|
||||||
|
'generated_at': '',
|
||||||
|
'store_name': '',
|
||||||
|
'business_type': '',
|
||||||
|
'ai_model': ''
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if 'business_type' not in data or not data['business_type']:
|
||||||
|
return jsonify({
|
||||||
|
'tip': '',
|
||||||
|
'status': 'error',
|
||||||
|
'message': '업종(business_type)은 필수입니다.',
|
||||||
|
'generated_at': '',
|
||||||
|
'store_name': '',
|
||||||
|
'business_type': '',
|
||||||
|
'ai_model': ''
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
logger.info(f"마케팅 팁 생성 요청: {data.get('store_name', 'Unknown')}")
|
||||||
|
|
||||||
|
# 요청 모델 생성
|
||||||
|
try:
|
||||||
|
request_model = MarketingTipGenerateRequest(**data)
|
||||||
|
except ValueError as e:
|
||||||
|
return jsonify({
|
||||||
|
'tip': '',
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'요청 데이터 형식이 올바르지 않습니다: {str(e)}',
|
||||||
|
'generated_at': '',
|
||||||
|
'store_name': data.get('store_name', ''),
|
||||||
|
'business_type': data.get('business_type', ''),
|
||||||
|
'ai_model': ''
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# 매장 정보 구성
|
||||||
|
store_data = {
|
||||||
|
'store_name': request_model.store_name,
|
||||||
|
'business_type': request_model.business_type,
|
||||||
|
'location': request_model.location or '',
|
||||||
|
'seat_count': request_model.seat_count or 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 마케팅 팁 생성
|
||||||
|
result = marketing_tip_service.generate_marketing_tip(
|
||||||
|
store_data=store_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"마케팅 팁 생성 완료: {result.get('store_name', 'Unknown')}")
|
||||||
|
|
||||||
|
return jsonify(result), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"마케팅 팁 생성 API 오류: {str(e)}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'tip': '죄송합니다. 일시적인 오류로 마케팅 팁을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'서버 오류가 발생했습니다: {str(e)}',
|
||||||
|
'generated_at': '',
|
||||||
|
'store_name': data.get('store_name', '') if 'data' in locals() else '',
|
||||||
|
'business_type': data.get('business_type', '') if 'data' in locals() else '',
|
||||||
|
'ai_model': 'error'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@marketing_tip_bp.route('/api/v1/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""
|
||||||
|
헬스체크 API
|
||||||
|
"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'service': 'marketing-tip-api',
|
||||||
|
'timestamp': datetime.now().isoformat()
|
||||||
|
}), 200
|
||||||
307
smarketing-ai/app.py
Normal file
307
smarketing-ai/app.py
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
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.sns_content_service import SnsContentService
|
||||||
|
from services.poster_service import PosterService
|
||||||
|
from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest
|
||||||
|
from api.marketing_tip_api import marketing_tip_bp
|
||||||
|
|
||||||
|
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()
|
||||||
|
sns_content_service = SnsContentService()
|
||||||
|
|
||||||
|
# Blueprint 등록
|
||||||
|
app.register_blueprint(marketing_tip_bp)
|
||||||
|
|
||||||
|
@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'),
|
||||||
|
storeName=data.get('storeName'),
|
||||||
|
storeType=data.get('storeType'),
|
||||||
|
target=data.get('target'),
|
||||||
|
#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.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 (기존)
|
||||||
|
점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
host = os.getenv('SERVER_HOST', '0.0.0.0')
|
||||||
|
port = int(os.getenv('SERVER_PORT', '5001'))
|
||||||
|
|
||||||
|
app.run(host=host, port=port, debug=True)
|
||||||
1
smarketing-ai/config/__init__.py
Normal file
1
smarketing-ai/config/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
39
smarketing-ai/config/config.py
Normal file
39
smarketing-ai/config/config.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
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 * 1536) # 16MB
|
||||||
|
|
||||||
|
# AI API 설정
|
||||||
|
CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY')
|
||||||
|
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
|
||||||
|
|
||||||
|
# Azure Blob Storage 설정
|
||||||
|
AZURE_STORAGE_ACCOUNT_NAME = os.environ.get('AZURE_STORAGE_ACCOUNT_NAME') or 'stdigitalgarage02'
|
||||||
|
AZURE_STORAGE_ACCOUNT_KEY = os.environ.get('AZURE_STORAGE_ACCOUNT_KEY')
|
||||||
|
AZURE_STORAGE_CONTAINER_NAME = os.environ.get('AZURE_STORAGE_CONTAINER_NAME') or 'ai-content'
|
||||||
|
|
||||||
|
# 지원되는 파일 확장자
|
||||||
|
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
|
||||||
15
smarketing-ai/deployment/Dockerfile
Normal file
15
smarketing-ai/deployment/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 애플리케이션 코드 복사
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 포트 노출
|
||||||
|
EXPOSE 5001
|
||||||
|
|
||||||
|
# 애플리케이션 실행
|
||||||
|
CMD ["python", "app.py"]
|
||||||
153
smarketing-ai/deployment/Jenkinsfile
vendored
Normal file
153
smarketing-ai/deployment/Jenkinsfile
vendored
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
def PIPELINE_ID = "${env.BUILD_NUMBER}"
|
||||||
|
|
||||||
|
def getImageTag() {
|
||||||
|
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
|
||||||
|
def currentDate = new Date()
|
||||||
|
return dateFormat.format(currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
podTemplate(
|
||||||
|
label: "${PIPELINE_ID}",
|
||||||
|
serviceAccount: 'jenkins',
|
||||||
|
containers: [
|
||||||
|
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
|
||||||
|
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
|
||||||
|
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
|
||||||
|
],
|
||||||
|
volumes: [
|
||||||
|
emptyDirVolume(mountPath: '/run/podman', memory: false),
|
||||||
|
emptyDirVolume(mountPath: '/root/.azure', memory: false)
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
node(PIPELINE_ID) {
|
||||||
|
def props
|
||||||
|
def imageTag = getImageTag()
|
||||||
|
def manifest = "deploy.yaml"
|
||||||
|
def namespace
|
||||||
|
|
||||||
|
stage("Get Source") {
|
||||||
|
checkout scm
|
||||||
|
props = readProperties file: "deployment/deploy_env_vars"
|
||||||
|
namespace = "${props.namespace}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Setup AKS") {
|
||||||
|
container('azure-cli') {
|
||||||
|
withCredentials([azureServicePrincipal('azure-credentials')]) {
|
||||||
|
sh """
|
||||||
|
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
|
||||||
|
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
|
||||||
|
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push Docker Image') {
|
||||||
|
container('podman') {
|
||||||
|
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
|
||||||
|
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'acr-credentials',
|
||||||
|
usernameVariable: 'ACR_USERNAME',
|
||||||
|
passwordVariable: 'ACR_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh """
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Building smarketing-ai Python Flask application"
|
||||||
|
echo "Image Tag: ${imageTag}"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# ACR 로그인
|
||||||
|
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
|
||||||
|
|
||||||
|
# Docker 이미지 빌드
|
||||||
|
podman build \
|
||||||
|
-f deployment/container/Dockerfile \
|
||||||
|
-t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} .
|
||||||
|
|
||||||
|
# 이미지 푸시
|
||||||
|
podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
|
||||||
|
|
||||||
|
echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Generate & Apply Manifest') {
|
||||||
|
container('envsubst') {
|
||||||
|
withCredentials([
|
||||||
|
string(credentialsId: 'secret-key', variable: 'SECRET_KEY'),
|
||||||
|
string(credentialsId: 'claude-api-key', variable: 'CLAUDE_API_KEY'),
|
||||||
|
string(credentialsId: 'openai-api-key', variable: 'OPENAI_API_KEY'),
|
||||||
|
string(credentialsId: 'azure-storage-account-name', variable: 'AZURE_STORAGE_ACCOUNT_NAME'),
|
||||||
|
string(credentialsId: 'azure-storage-account-key', variable: 'AZURE_STORAGE_ACCOUNT_KEY')
|
||||||
|
]) {
|
||||||
|
sh """
|
||||||
|
export namespace=${namespace}
|
||||||
|
export replicas=${props.replicas}
|
||||||
|
export resources_requests_cpu=${props.resources_requests_cpu}
|
||||||
|
export resources_requests_memory=${props.resources_requests_memory}
|
||||||
|
export resources_limits_cpu=${props.resources_limits_cpu}
|
||||||
|
export resources_limits_memory=${props.resources_limits_memory}
|
||||||
|
export upload_folder=${props.upload_folder}
|
||||||
|
export max_content_length=${props.max_content_length}
|
||||||
|
export allowed_extensions=${props.allowed_extensions}
|
||||||
|
export server_host=${props.server_host}
|
||||||
|
export server_port=${props.server_port}
|
||||||
|
export azure_storage_container_name=${props.azure_storage_container_name}
|
||||||
|
|
||||||
|
# 이미지 경로 환경변수 설정
|
||||||
|
export smarketing_image_path=${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
|
||||||
|
|
||||||
|
# Sensitive 환경변수 설정 (Jenkins Credentials에서)
|
||||||
|
export secret_key=\$SECRET_KEY
|
||||||
|
export claude_api_key=\$CLAUDE_API_KEY
|
||||||
|
export openai_api_key=\$OPENAI_API_KEY
|
||||||
|
export azure_storage_account_name=\$AZURE_STORAGE_ACCOUNT_NAME
|
||||||
|
export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY
|
||||||
|
|
||||||
|
# manifest 생성
|
||||||
|
envsubst < deployment/${manifest}.template > deployment/${manifest}
|
||||||
|
echo "Generated manifest file:"
|
||||||
|
cat deployment/${manifest}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container('azure-cli') {
|
||||||
|
sh """
|
||||||
|
kubectl apply -f deployment/${manifest}
|
||||||
|
|
||||||
|
echo "Waiting for smarketing deployment to be ready..."
|
||||||
|
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Getting LoadBalancer External IP..."
|
||||||
|
|
||||||
|
# External IP 확인 (최대 5분 대기)
|
||||||
|
for i in {1..30}; do
|
||||||
|
EXTERNAL_IP=\$(kubectl -n ${namespace} get service smarketing-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
|
||||||
|
if [ "\$EXTERNAL_IP" != "" ] && [ "\$EXTERNAL_IP" != "null" ]; then
|
||||||
|
echo "External IP assigned: \$EXTERNAL_IP"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for External IP... (attempt \$i/30)"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
|
||||||
|
# 서비스 상태 확인
|
||||||
|
kubectl -n ${namespace} get pods -l app=smarketing
|
||||||
|
kubectl -n ${namespace} get service smarketing-service
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Deployment Complete!"
|
||||||
|
echo "Service URL: http://\$EXTERNAL_IP:${props.server_port}"
|
||||||
|
echo "Health Check: http://\$EXTERNAL_IP:${props.server_port}/health"
|
||||||
|
echo "=========================================="
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
170
smarketing-ai/deployment/Jenkinsfile_ArgoCD
Normal file
170
smarketing-ai/deployment/Jenkinsfile_ArgoCD
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
def PIPELINE_ID = "${env.BUILD_NUMBER}"
|
||||||
|
|
||||||
|
def getImageTag() {
|
||||||
|
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
|
||||||
|
def currentDate = new Date()
|
||||||
|
return dateFormat.format(currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
podTemplate(
|
||||||
|
label: "${PIPELINE_ID}",
|
||||||
|
serviceAccount: 'jenkins',
|
||||||
|
containers: [
|
||||||
|
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
|
||||||
|
containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
|
||||||
|
],
|
||||||
|
volumes: [
|
||||||
|
emptyDirVolume(mountPath: '/run/podman', memory: false)
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
node(PIPELINE_ID) {
|
||||||
|
def props
|
||||||
|
def imageTag = getImageTag()
|
||||||
|
|
||||||
|
stage("Get Source") {
|
||||||
|
checkout scm
|
||||||
|
props = readProperties file: "deployment/deploy_env_vars"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push Docker Image') {
|
||||||
|
container('podman') {
|
||||||
|
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
|
||||||
|
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'acr-credentials',
|
||||||
|
usernameVariable: 'ACR_USERNAME',
|
||||||
|
passwordVariable: 'ACR_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh """
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Building smarketing-ai for ArgoCD GitOps"
|
||||||
|
echo "Image Tag: ${imageTag}"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# ACR 로그인
|
||||||
|
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
|
||||||
|
|
||||||
|
# Docker 이미지 빌드
|
||||||
|
podman build \
|
||||||
|
-f deployment/container/Dockerfile \
|
||||||
|
-t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} .
|
||||||
|
|
||||||
|
# 이미지 푸시
|
||||||
|
podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
|
||||||
|
|
||||||
|
echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Update Manifest Repository') {
|
||||||
|
container('git') {
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'github-credentials-${props.teamid}',
|
||||||
|
usernameVariable: 'GIT_USERNAME',
|
||||||
|
passwordVariable: 'GIT_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh """
|
||||||
|
# Git 설정
|
||||||
|
git config --global user.email "jenkins@company.com"
|
||||||
|
git config --global user.name "Jenkins CI"
|
||||||
|
|
||||||
|
# Manifest 저장소 클론 (팀별 저장소로 수정 필요)
|
||||||
|
git clone https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/your-team/smarketing-ai-manifest.git
|
||||||
|
cd smarketing-ai-manifest
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Updating smarketing-ai manifest repository:"
|
||||||
|
echo "New Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
|
||||||
|
|
||||||
|
# smarketing deployment 파일 업데이트
|
||||||
|
if [ -f "smarketing/smarketing-deployment.yaml" ]; then
|
||||||
|
# 이미지 태그 업데이트
|
||||||
|
sed -i "s|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}|g" \
|
||||||
|
smarketing/smarketing-deployment.yaml
|
||||||
|
|
||||||
|
echo "Updated smarketing deployment to image tag: ${imageTag}"
|
||||||
|
cat smarketing/smarketing-deployment.yaml | grep "image:"
|
||||||
|
else
|
||||||
|
echo "Warning: smarketing-deployment.yaml not found"
|
||||||
|
echo "Creating manifest directory structure..."
|
||||||
|
|
||||||
|
# 기본 구조 생성
|
||||||
|
mkdir -p smarketing
|
||||||
|
|
||||||
|
# 기본 deployment 파일 생성
|
||||||
|
cat > smarketing/smarketing-deployment.yaml << EOF
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smarketing
|
||||||
|
namespace: smarketing
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smarketing
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: smarketing
|
||||||
|
image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 5001
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 256m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1024m
|
||||||
|
memory: 2048Mi
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: smarketing-config
|
||||||
|
- secretRef:
|
||||||
|
name: smarketing-secret
|
||||||
|
volumeMounts:
|
||||||
|
- name: upload-storage
|
||||||
|
mountPath: /app/uploads
|
||||||
|
- name: temp-storage
|
||||||
|
mountPath: /app/uploads/temp
|
||||||
|
volumes:
|
||||||
|
- name: upload-storage
|
||||||
|
emptyDir: {}
|
||||||
|
- name: temp-storage
|
||||||
|
emptyDir: {}
|
||||||
|
EOF
|
||||||
|
echo "Created basic smarketing-deployment.yaml"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 변경사항 커밋 및 푸시
|
||||||
|
git add .
|
||||||
|
git commit -m "Update smarketing-ai image tag to ${imageTag}
|
||||||
|
|
||||||
|
Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
|
||||||
|
Build: ${env.BUILD_NUMBER}
|
||||||
|
Branch: ${env.BRANCH_NAME}
|
||||||
|
Commit: ${env.GIT_COMMIT}"
|
||||||
|
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "ArgoCD GitOps Update Completed!"
|
||||||
|
echo "Updated Service: smarketing-ai:${imageTag}"
|
||||||
|
echo "ArgoCD will automatically detect and deploy these changes."
|
||||||
|
echo "=========================================="
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
smarketing-ai/deployment/deploy.yaml.template
Normal file
113
smarketing-ai/deployment/deploy.yaml.template
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# ConfigMap
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: smarketing-config
|
||||||
|
namespace: ${namespace}
|
||||||
|
data:
|
||||||
|
SERVER_HOST: "${server_host}"
|
||||||
|
SERVER_PORT: "${server_port}"
|
||||||
|
UPLOAD_FOLDER: "${upload_folder}"
|
||||||
|
MAX_CONTENT_LENGTH: "${max_content_length}"
|
||||||
|
ALLOWED_EXTENSIONS: "${allowed_extensions}"
|
||||||
|
AZURE_STORAGE_CONTAINER_NAME: "${azure_storage_container_name}"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Secret
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: smarketing-secret
|
||||||
|
namespace: ${namespace}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
SECRET_KEY: "${secret_key}"
|
||||||
|
CLAUDE_API_KEY: "${claude_api_key}"
|
||||||
|
OPENAI_API_KEY: "${openai_api_key}"
|
||||||
|
AZURE_STORAGE_ACCOUNT_NAME: "${azure_storage_account_name}"
|
||||||
|
AZURE_STORAGE_ACCOUNT_KEY: "${azure_storage_account_key}"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Deployment
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smarketing
|
||||||
|
namespace: ${namespace}
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
replicas: ${replicas}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smarketing
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: smarketing
|
||||||
|
image: ${smarketing_image_path}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 5001
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: ${resources_requests_cpu}
|
||||||
|
memory: ${resources_requests_memory}
|
||||||
|
limits:
|
||||||
|
cpu: ${resources_limits_cpu}
|
||||||
|
memory: ${resources_limits_memory}
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: smarketing-config
|
||||||
|
- secretRef:
|
||||||
|
name: smarketing-secret
|
||||||
|
volumeMounts:
|
||||||
|
- name: upload-storage
|
||||||
|
mountPath: /app/uploads
|
||||||
|
- name: temp-storage
|
||||||
|
mountPath: /app/uploads/temp
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 5001
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 5001
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
volumes:
|
||||||
|
- name: upload-storage
|
||||||
|
emptyDir: {}
|
||||||
|
- name: temp-storage
|
||||||
|
emptyDir: {}
|
||||||
|
|
||||||
|
---
|
||||||
|
# Service (LoadBalancer type for External IP)
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: smarketing-service
|
||||||
|
namespace: ${namespace}
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
ports:
|
||||||
|
- port: 5001
|
||||||
|
targetPort: 5001
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: smarketing
|
||||||
27
smarketing-ai/deployment/deploy_env_vars
Normal file
27
smarketing-ai/deployment/deploy_env_vars
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Team Settings
|
||||||
|
teamid=won
|
||||||
|
root_project=smarketing-ai
|
||||||
|
namespace=smarketing
|
||||||
|
|
||||||
|
# Container Registry Settings
|
||||||
|
registry=acrdigitalgarage02.azurecr.io
|
||||||
|
image_org=won
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
replicas=1
|
||||||
|
|
||||||
|
# Resource Settings
|
||||||
|
resources_requests_cpu=256m
|
||||||
|
resources_requests_memory=512Mi
|
||||||
|
resources_limits_cpu=1024m
|
||||||
|
resources_limits_memory=2048Mi
|
||||||
|
|
||||||
|
# Flask App Settings (non-sensitive)
|
||||||
|
upload_folder=/app/uploads
|
||||||
|
max_content_length=16777216
|
||||||
|
allowed_extensions=png,jpg,jpeg,gif,webp
|
||||||
|
server_host=0.0.0.0
|
||||||
|
server_port=5001
|
||||||
|
|
||||||
|
# Azure Storage Settings (non-sensitive)
|
||||||
|
azure_storage_container_name=ai-content
|
||||||
11
smarketing-ai/deployment/manifest/configmap.yaml
Normal file
11
smarketing-ai/deployment/manifest/configmap.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: smarketing-config
|
||||||
|
namespace: smarketing
|
||||||
|
data:
|
||||||
|
SERVER_HOST: "0.0.0.0"
|
||||||
|
SERVER_PORT: "5001"
|
||||||
|
UPLOAD_FOLDER: "/app/uploads"
|
||||||
|
MAX_CONTENT_LENGTH: "16777216" # 16MB
|
||||||
|
ALLOWED_EXTENSIONS: "png,jpg,jpeg,gif,webp"
|
||||||
47
smarketing-ai/deployment/manifest/deployment.yaml
Normal file
47
smarketing-ai/deployment/manifest/deployment.yaml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: smarketing
|
||||||
|
namespace: smarketing
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: smarketing
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: smarketing
|
||||||
|
image: acrdigitalgarage02.azurecr.io/smarketing-ai:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 5001
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 256m
|
||||||
|
memory: 512Mi
|
||||||
|
limits:
|
||||||
|
cpu: 1024m
|
||||||
|
memory: 2048Mi
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: smarketing-config
|
||||||
|
- secretRef:
|
||||||
|
name: smarketing-secret
|
||||||
|
volumeMounts:
|
||||||
|
- name: upload-storage
|
||||||
|
mountPath: /app/uploads
|
||||||
|
- name: temp-storage
|
||||||
|
mountPath: /app/uploads/temp
|
||||||
|
volumes:
|
||||||
|
- name: upload-storage
|
||||||
|
emptyDir: {}
|
||||||
|
- name: temp-storage
|
||||||
|
emptyDir: {}
|
||||||
26
smarketing-ai/deployment/manifest/ingress.yaml
Normal file
26
smarketing-ai/deployment/manifest/ingress.yaml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: smarketing-ingress
|
||||||
|
namespace: smarketing
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: nginx
|
||||||
|
nginx.ingress.kubernetes.io/proxy-body-size: "16m"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "300"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization"
|
||||||
|
nginx.ingress.kubernetes.io/cors-allow-origin: "*"
|
||||||
|
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: smarketing.20.249.184.228.nip.io
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: smarketing-service
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
9
smarketing-ai/deployment/manifest/secret.yaml
Normal file
9
smarketing-ai/deployment/manifest/secret.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: smarketing-secret
|
||||||
|
namespace: smarketing
|
||||||
|
type: Opaque
|
||||||
|
|
||||||
|
data:
|
||||||
|
OPENAI_API_KEY: c2stcHJvai1BbjRRX3VTNnNzQkxLU014VXBYTDBPM0lteUJuUjRwNVFTUHZkRnNSeXpFWGE0M21ISnhBcUkzNGZQOEduV2ZxclBpQ29VZ2pmbFQzQmxia0ZKZklMUGVqUFFIem9ZYzU4Yzc4UFkzeUo0dkowTVlfNGMzNV82dFlQUlkzTDBIODAwWWVvMnpaTmx6V3hXNk1RMFRzSDg5T1lNWUEK
|
||||||
16
smarketing-ai/deployment/manifest/service.yaml
Normal file
16
smarketing-ai/deployment/manifest/service.yaml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: smarketing-service
|
||||||
|
namespace: smarketing
|
||||||
|
labels:
|
||||||
|
app: smarketing
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
ports:
|
||||||
|
- port: 5001
|
||||||
|
targetPort: 5001
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: smarketing
|
||||||
1
smarketing-ai/models/__init__.py
Normal file
1
smarketing-ai/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
93
smarketing-ai/models/marketing_tip_models.py
Normal file
93
smarketing-ai/models/marketing_tip_models.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
마케팅 팁 API 요청/응답 모델
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MenuInfo(BaseModel):
|
||||||
|
"""메뉴 정보 모델"""
|
||||||
|
|
||||||
|
menu_id: int = Field(..., description="메뉴 ID")
|
||||||
|
menu_name: str = Field(..., description="메뉴명")
|
||||||
|
category: str = Field(..., description="메뉴 카테고리")
|
||||||
|
price: int = Field(..., description="가격")
|
||||||
|
description: Optional[str] = Field(None, description="메뉴 설명")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"store_name": "더블샷 카페",
|
||||||
|
"business_type": "카페",
|
||||||
|
"location": "서울시 강남구 역삼동",
|
||||||
|
"seat_count": 30,
|
||||||
|
"menu_list": [
|
||||||
|
{
|
||||||
|
"menu_id": 1,
|
||||||
|
"menu_name": "아메리카노",
|
||||||
|
"category": "음료",
|
||||||
|
"price": 4000,
|
||||||
|
"description": "깊고 진한 맛의 아메리카노"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menu_id": 2,
|
||||||
|
"menu_name": "카페라떼",
|
||||||
|
"category": "음료",
|
||||||
|
"price": 4500,
|
||||||
|
"description": "부드러운 우유 거품이 올라간 카페라떼"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"menu_id": 3,
|
||||||
|
"menu_name": "치즈케이크",
|
||||||
|
"category": "디저트",
|
||||||
|
"price": 6000,
|
||||||
|
"description": "진한 치즈 맛의 수제 케이크"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"additional_requirement": "젊은 고객층을 타겟으로 한 마케팅"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarketingTipGenerateRequest(BaseModel):
|
||||||
|
"""마케팅 팁 생성 요청 모델"""
|
||||||
|
|
||||||
|
store_name: str = Field(..., description="매장명")
|
||||||
|
business_type: str = Field(..., description="업종")
|
||||||
|
location: Optional[str] = Field(None, description="위치")
|
||||||
|
seat_count: Optional[int] = Field(None, description="좌석 수")
|
||||||
|
menu_list: Optional[List[MenuInfo]] = Field(default=[], description="메뉴 목록")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"store_name": "더블샷 카페",
|
||||||
|
"business_type": "카페",
|
||||||
|
"location": "서울시 강남구 역삼동",
|
||||||
|
"seat_count": 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarketingTipResponse(BaseModel):
|
||||||
|
"""마케팅 팁 응답 모델"""
|
||||||
|
|
||||||
|
tip: str = Field(..., description="생성된 마케팅 팁")
|
||||||
|
status: str = Field(..., description="응답 상태 (success, fallback, error)")
|
||||||
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
generated_at: str = Field(..., description="생성 시간")
|
||||||
|
store_name: str = Field(..., description="매장명")
|
||||||
|
business_type: str = Field(..., description="업종")
|
||||||
|
ai_model: str = Field(..., description="사용된 AI 모델")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"tip": "☕ 더블샷 카페 여름 마케팅 전략\n\n💡 핵심 포인트:\n1. 여름 한정 시원한 음료 개발\n2. SNS 이벤트로 젊은 고객층 공략\n3. 더위 피할 수 있는 쾌적한 환경 어필",
|
||||||
|
"status": "success",
|
||||||
|
"message": "AI 마케팅 팁이 성공적으로 생성되었습니다.",
|
||||||
|
"generated_at": "2024-06-13T15:30:00",
|
||||||
|
"store_name": "더블샷 카페",
|
||||||
|
"business_type": "카페",
|
||||||
|
"ai_model": "claude"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
smarketing-ai/models/request_models.py
Normal file
71
smarketing-ai/models/request_models.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
요청 모델 정의
|
||||||
|
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 리스트
|
||||||
|
target : Optional[str] = None # 타켓
|
||||||
|
requirement: Optional[str] = None
|
||||||
|
storeName: Optional[str] = None
|
||||||
|
storeType: 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
|
||||||
9
smarketing-ai/requirements.txt
Normal file
9
smarketing-ai/requirements.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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
|
||||||
|
azure-storage-blob>=12.19.0
|
||||||
1
smarketing-ai/services/__init__.py
Normal file
1
smarketing-ai/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
331
smarketing-ai/services/marketing_tip_service.py
Normal file
331
smarketing-ai/services/marketing_tip_service.py
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
마케팅 팁 생성 서비스
|
||||||
|
Java 서비스에서 요청받은 매장 정보를 기반으로 AI 마케팅 팁을 생성
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
import anthropic
|
||||||
|
import openai
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketingTipService:
|
||||||
|
"""마케팅 팁 생성 서비스 클래스"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""서비스 초기화"""
|
||||||
|
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
|
||||||
|
logger.warning("Claude API 키가 설정되지 않았습니다.")
|
||||||
|
|
||||||
|
# OpenAI 클라이언트 초기화
|
||||||
|
if self.openai_api_key:
|
||||||
|
self.openai_client = openai.OpenAI(api_key=self.openai_api_key)
|
||||||
|
else:
|
||||||
|
self.openai_client = None
|
||||||
|
logger.warning("OpenAI API 키가 설정되지 않았습니다.")
|
||||||
|
|
||||||
|
def generate_marketing_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
매장 정보를 기반으로 AI 마케팅 팁 생성
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store_data: 매장 정보 (store_name, business_type, location 등)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
생성된 마케팅 팁과 메타데이터
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"마케팅 팁 생성 시작: {store_data.get('store_name', 'Unknown')}")
|
||||||
|
|
||||||
|
# 1. 프롬프트 생성
|
||||||
|
prompt = self._create_marketing_prompt(store_data, additional_requirement)
|
||||||
|
|
||||||
|
# 2. AI 서비스 호출 (Claude 우선, 실패 시 OpenAI)
|
||||||
|
tip_content = self._call_ai_service(prompt)
|
||||||
|
|
||||||
|
# 3. 응답 데이터 구성
|
||||||
|
response = {
|
||||||
|
'tip': tip_content,
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'AI 마케팅 팁이 성공적으로 생성되었습니다.',
|
||||||
|
'generated_at': datetime.now().isoformat(),
|
||||||
|
'store_name': store_data.get('store_name', ''),
|
||||||
|
'business_type': store_data.get('business_type', ''),
|
||||||
|
'ai_model': 'claude' if self.claude_client else 'openai'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"마케팅 팁 생성 완료: {store_data.get('store_name', 'Unknown')}")
|
||||||
|
logger.info(f"마케팅 팁 생성 완료: {response}")
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"마케팅 팁 생성 실패: {str(e)}")
|
||||||
|
|
||||||
|
# 실패 시 Fallback 팁 반환
|
||||||
|
fallback_tip = self._create_fallback_tip(store_data, additional_requirement)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'tip': fallback_tip,
|
||||||
|
'status': 'fallback',
|
||||||
|
'message': 'AI 서비스 호출 실패로 기본 팁을 제공합니다.',
|
||||||
|
'generated_at': datetime.now().isoformat(),
|
||||||
|
'store_name': store_data.get('store_name', ''),
|
||||||
|
'business_type': store_data.get('business_type', ''),
|
||||||
|
'ai_model': 'fallback'
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_marketing_prompt(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str:
|
||||||
|
"""마케팅 팁 생성을 위한 프롬프트 생성"""
|
||||||
|
|
||||||
|
store_name = store_data.get('store_name', '매장')
|
||||||
|
business_type = store_data.get('business_type', '소상공인')
|
||||||
|
location = store_data.get('location', '')
|
||||||
|
seat_count = store_data.get('seat_count', 0)
|
||||||
|
menu_list = store_data.get('menu_list', [])
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
당신은 소상공인 마케팅 전문가입니다.
|
||||||
|
현재 유행하고 성공한 마케팅 예시를 검색하여 확인 한 후, 참고하여 아래 내용을 작성해주세요.
|
||||||
|
|
||||||
|
당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 낼 수 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다.
|
||||||
|
지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요.
|
||||||
|
소상공인을 위한 실용적인 마케팅 팁을 생성해주세요.
|
||||||
|
|
||||||
|
매장 정보:
|
||||||
|
- 매장명: {store_name}
|
||||||
|
- 업종: {business_type}
|
||||||
|
- 위치: {location}
|
||||||
|
- 좌석 수: {seat_count}석
|
||||||
|
|
||||||
|
"""
|
||||||
|
# 🔥 메뉴 정보 추가
|
||||||
|
if menu_list and len(menu_list) > 0:
|
||||||
|
prompt += f"\n메뉴 정보:\n"
|
||||||
|
for menu in menu_list:
|
||||||
|
menu_name = menu.get('menu_name', '')
|
||||||
|
category = menu.get('category', '')
|
||||||
|
price = menu.get('price', 0)
|
||||||
|
description = menu.get('description', '')
|
||||||
|
prompt += f"- {menu_name} ({category}): {price:,}원 - {description}\n"
|
||||||
|
|
||||||
|
prompt += """
|
||||||
|
아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요:
|
||||||
|
|
||||||
|
1. **실행 가능성**: 소상공인이 실제로 적용할 수 있는 현실적인 방법
|
||||||
|
2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 수 있는 전략
|
||||||
|
3. **구체성**: 실행 단계가 명확하고 구체적일 것
|
||||||
|
4. **시의성**: 현재 계절, 유행, 트렌드를 반영
|
||||||
|
5. **지역성**: 지역 특성 및 현재 날씨를 고려할 것
|
||||||
|
|
||||||
|
응답 형식 (300자 내외, 간결하게):
|
||||||
|
html 형식으로 출력
|
||||||
|
핵심 마케팅 팁은 제목없이 한번 더 상단에 보여주세요
|
||||||
|
부제목과 내용은 분리해서 출력
|
||||||
|
아래의 부제목 앞에는 이모지 포함
|
||||||
|
- 핵심 마케팅 팁 (1개)
|
||||||
|
- 실행 방법 (1개)
|
||||||
|
- 예상 비용과 기대 효과
|
||||||
|
- 주의사항 또는 유의점
|
||||||
|
- 참고했던 실제 성공한 마케팅
|
||||||
|
- 오늘의 응원의 문장 (간결하게 1개)
|
||||||
|
|
||||||
|
심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _call_ai_service(self, prompt: str) -> str:
|
||||||
|
"""AI 서비스 호출"""
|
||||||
|
|
||||||
|
# Claude API 우선 시도
|
||||||
|
if self.claude_client:
|
||||||
|
try:
|
||||||
|
response = self.claude_client.messages.create(
|
||||||
|
model="claude-3-sonnet-20240229",
|
||||||
|
max_tokens=1000,
|
||||||
|
temperature=0.7,
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.content and len(response.content) > 0:
|
||||||
|
logger.info(f"마케팅 팁 생성 완료: {response.content}")
|
||||||
|
return response.content[0].text.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Claude API 호출 실패: {str(e)}")
|
||||||
|
|
||||||
|
# OpenAI API 시도
|
||||||
|
if self.openai_client:
|
||||||
|
try:
|
||||||
|
response = self.openai_client.chat.completions.create(
|
||||||
|
model="gpt-3.5-turbo",
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "당신은 소상공인을 위한 마케팅 전문가입니다. 실용적이고 구체적인 마케팅 조언을 제공해주세요."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
max_tokens=800,
|
||||||
|
temperature=0.7
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.choices and len(response.choices) > 0:
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"OpenAI API 호출 실패: {str(e)}")
|
||||||
|
|
||||||
|
# 모든 AI 서비스 호출 실패
|
||||||
|
raise Exception("모든 AI 서비스 호출에 실패했습니다.")
|
||||||
|
|
||||||
|
def _create_fallback_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str:
|
||||||
|
"""AI 서비스 실패 시 규칙 기반 Fallback 팁 생성"""
|
||||||
|
|
||||||
|
store_name = store_data.get('store_name', '매장')
|
||||||
|
business_type = store_data.get('business_type', '')
|
||||||
|
location = store_data.get('location', '')
|
||||||
|
menu_list = store_data.get('menu_list', [])
|
||||||
|
|
||||||
|
if menu_list and len(menu_list) > 0:
|
||||||
|
# 가장 비싼 메뉴 찾기 (시그니처 메뉴로 가정)
|
||||||
|
expensive_menu = max(menu_list, key=lambda x: x.get('price', 0), default=None)
|
||||||
|
|
||||||
|
# 카테고리별 메뉴 분석
|
||||||
|
categories = {}
|
||||||
|
for menu in menu_list:
|
||||||
|
category = menu.get('category', '기타')
|
||||||
|
if category not in categories:
|
||||||
|
categories[category] = []
|
||||||
|
categories[category].append(menu)
|
||||||
|
|
||||||
|
main_category = max(categories.keys(), key=lambda x: len(categories[x])) if categories else '메뉴'
|
||||||
|
|
||||||
|
if expensive_menu:
|
||||||
|
signature_menu = expensive_menu.get('menu_name', '시그니처 메뉴')
|
||||||
|
return f"""🎯 {store_name} 메뉴 기반 마케팅 전략
|
||||||
|
|
||||||
|
💡 핵심 전략:
|
||||||
|
- SNS를 활용한 홍보 강화
|
||||||
|
- 고객 리뷰 관리 및 적극 활용
|
||||||
|
- 지역 커뮤니티 참여로 인지도 향상
|
||||||
|
|
||||||
|
📱 실행 방법:
|
||||||
|
1. 인스타그램/네이버 블로그 정기 포스팅
|
||||||
|
2. 고객 만족도 조사 및 피드백 반영
|
||||||
|
3. 주변 상권과의 협력 이벤트 기획
|
||||||
|
|
||||||
|
💰 예상 효과: 월 매출 10-15% 증가 가능
|
||||||
|
⚠️ 주의사항: 꾸준한 실행과 고객 소통이 핵심"""
|
||||||
|
|
||||||
|
# 업종별 기본 팁
|
||||||
|
if '카페' in business_type or '커피' in business_type:
|
||||||
|
return f"""☕ {store_name} 카페 마케팅 전략
|
||||||
|
|
||||||
|
💡 핵심 포인트:
|
||||||
|
1. 시그니처 음료 개발 및 SNS 홍보
|
||||||
|
2. 계절별 한정 메뉴로 재방문 유도
|
||||||
|
3. 인스타그램 포토존 설치
|
||||||
|
|
||||||
|
📱 실행 방법:
|
||||||
|
- 매주 신메뉴 또는 이벤트 인스타 포스팅
|
||||||
|
- 고객 사진 리포스트로 참여 유도
|
||||||
|
- 해시태그 #근처카페 #데이트코스 활용
|
||||||
|
|
||||||
|
💰 비용: 월 5-10만원 내외
|
||||||
|
📈 기대효과: 젊은 고객층 20% 증가"""
|
||||||
|
|
||||||
|
elif '음식점' in business_type or '식당' in business_type:
|
||||||
|
return f"""🍽️ {store_name} 음식점 마케팅 전략
|
||||||
|
|
||||||
|
💡 핵심 포인트:
|
||||||
|
1. 대표 메뉴 스토리텔링
|
||||||
|
2. 배달앱 리뷰 관리 강화
|
||||||
|
3. 단골 고객 혜택 프로그램
|
||||||
|
|
||||||
|
📱 실행 방법:
|
||||||
|
- 요리 과정 영상으로 신뢰도 구축
|
||||||
|
- 리뷰 적극 답변으로 고객 관리
|
||||||
|
- 방문 횟수별 할인 혜택 제공
|
||||||
|
|
||||||
|
💰 비용: 월 3-7만원 내외
|
||||||
|
📈 기대효과: 재방문율 25% 향상"""
|
||||||
|
|
||||||
|
elif '베이커리' in business_type or '빵집' in business_type:
|
||||||
|
return f"""🍞 {store_name} 베이커리 마케팅 전략
|
||||||
|
|
||||||
|
💡 핵심 포인트:
|
||||||
|
1. 갓 구운 빵 타이밍 알림 서비스
|
||||||
|
2. 계절 한정 빵 출시
|
||||||
|
3. 포장 디자인으로 선물용 어필
|
||||||
|
|
||||||
|
📱 실행 방법:
|
||||||
|
- 네이버 톡톡으로 빵 완성 시간 안내
|
||||||
|
- 명절/기념일 특별 빵 한정 판매
|
||||||
|
- 예쁜 포장지로 브랜딩 강화
|
||||||
|
|
||||||
|
💰 비용: 월 5-8만원 내외
|
||||||
|
📈 기대효과: 단골 고객 30% 증가"""
|
||||||
|
|
||||||
|
# 지역별 특성 고려
|
||||||
|
if location:
|
||||||
|
location_tip = ""
|
||||||
|
if '강남' in location or '서초' in location:
|
||||||
|
location_tip = "\n🏢 강남권 특화: 직장인 대상 점심 세트메뉴 강화"
|
||||||
|
elif '홍대' in location or '신촌' in location:
|
||||||
|
location_tip = "\n🎓 대학가 특화: 학생 할인 및 그룹 이벤트 진행"
|
||||||
|
elif '강북' in location or '노원' in location:
|
||||||
|
location_tip = "\n🏘️ 주거지역 특화: 가족 단위 고객 대상 패키지 상품"
|
||||||
|
|
||||||
|
return f"""🎯 {store_name} 지역 맞춤 마케팅
|
||||||
|
|
||||||
|
💡 기본 전략:
|
||||||
|
- 온라인 리뷰 관리 강화
|
||||||
|
- 단골 고객 혜택 프로그램
|
||||||
|
- 지역 커뮤니티 참여{location_tip}
|
||||||
|
|
||||||
|
📱 실행 방법:
|
||||||
|
1. 구글/네이버 지도 정보 최신화
|
||||||
|
2. 동네 맘카페 홍보 참여
|
||||||
|
3. 주변 상권과 상생 이벤트
|
||||||
|
|
||||||
|
💰 비용: 월 3-5만원
|
||||||
|
📈 기대효과: 인지도 및 매출 향상"""
|
||||||
|
|
||||||
|
# 기본 범용 팁
|
||||||
|
return f"""🎯 {store_name} 기본 마케팅 전략
|
||||||
|
|
||||||
|
💡 핵심 3가지:
|
||||||
|
1. 온라인 존재감 강화 (SNS, 리뷰 관리)
|
||||||
|
2. 고객 소통 및 피드백 활용
|
||||||
|
3. 차별화된 서비스 제공
|
||||||
|
|
||||||
|
📱 실행 방법:
|
||||||
|
- 네이버 플레이스, 구글 정보 최신화
|
||||||
|
- 고객 불만 신속 해결로 신뢰 구축
|
||||||
|
- 작은 이벤트라도 꾸준히 진행
|
||||||
|
|
||||||
|
💰 비용: 거의 무료 (시간 투자 위주)
|
||||||
|
📈 기대효과: 꾸준한 성장과 단골 확보
|
||||||
|
|
||||||
|
⚠️ 핵심은 지속성입니다!"""
|
||||||
202
smarketing-ai/services/poster_service.py
Normal file
202
smarketing-ai/services/poster_service.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
포스터 생성 서비스 V3
|
||||||
|
OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1개 + 프롬프트 내 예시 링크 10개)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 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개 분석 + 예시 링크 7개 프롬프트 제공)
|
||||||
|
"""
|
||||||
|
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, "1024x1536")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'content': image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _create_poster_prompt_v3(self, request: PosterContentGetRequest,
|
||||||
|
main_analysis: Dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 7개 포함)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 메인 이미지 정보 활용
|
||||||
|
main_description = main_analysis.get('description', '맛있는 음식')
|
||||||
|
main_colors = main_analysis.get('dominant_colors', [])
|
||||||
|
image_info = main_analysis.get('info', {})
|
||||||
|
|
||||||
|
# 이미지 크기 및 비율 정보
|
||||||
|
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"""
|
||||||
|
## 카페 홍보 포스터 디자인 요청
|
||||||
|
|
||||||
|
### 📋 기본 정보
|
||||||
|
카테고리: {request.category}
|
||||||
|
콘텐츠 타입: {request.contentType}
|
||||||
|
메뉴명: {request.menuName or '없음'}
|
||||||
|
메뉴 정보: {main_description}
|
||||||
|
|
||||||
|
### 📅 이벤트 기간
|
||||||
|
시작일: {request.startDate or '지금'}
|
||||||
|
종료일: {request.endDate or '한정 기간'}
|
||||||
|
이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요.
|
||||||
|
|
||||||
|
### 🎨 디자인 요구사항
|
||||||
|
메인 이미지 처리
|
||||||
|
- 기존 메인 이미지는 변경하지 않고 그대로 유지
|
||||||
|
- 포스터 전체 크기의 1/3 이하로 배치
|
||||||
|
- 이미지와 조화로운 작은 장식 이미지 추가
|
||||||
|
- 크기: {image_orientation}
|
||||||
|
|
||||||
|
텍스트 요소
|
||||||
|
- 메뉴명 (필수)
|
||||||
|
- 간단한 추가 홍보 문구 (새로 생성, 한글) 혹은 "{request.requirement or '눈길을 끄는 전문적인 디자인'}"라는 요구사항에 맞는 문구
|
||||||
|
- 메뉴명 외 추가되는 문구는 1줄만 작성
|
||||||
|
|
||||||
|
|
||||||
|
텍스트 배치 규칙
|
||||||
|
- 글자가 이미지 경계를 벗어나지 않도록 주의
|
||||||
|
- 모서리에 너무 가깝게 배치하지 말 것
|
||||||
|
- 적당한 크기로 가독성 확보
|
||||||
|
- 아기자기한 한글 폰트 사용
|
||||||
|
|
||||||
|
### 🎨 디자인 스타일
|
||||||
|
참조 이미지
|
||||||
|
{example_links}의 URL을 참고하여 비슷한 스타일로 제작
|
||||||
|
|
||||||
|
색상 가이드
|
||||||
|
{color_description}
|
||||||
|
전체적인 디자인 방향
|
||||||
|
|
||||||
|
타겟: 한국 카페 고객층
|
||||||
|
스타일: 화려하고 매력적인 디자인
|
||||||
|
목적: 소셜미디어 공유용 (적합한 크기)
|
||||||
|
톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼
|
||||||
|
|
||||||
|
### 🎯 최종 목표
|
||||||
|
고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작
|
||||||
|
"""
|
||||||
|
|
||||||
|
return prompt
|
||||||
2005
smarketing-ai/services/sns_content_service.py
Normal file
2005
smarketing-ai/services/sns_content_service.py
Normal file
File diff suppressed because one or more lines are too long
42
smarketing-ai/test.py
Normal file
42
smarketing-ai/test.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
마케팅 팁 API 테스트 스크립트
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def test_marketing_tip_api():
|
||||||
|
"""마케팅 팁 API 테스트"""
|
||||||
|
|
||||||
|
# 테스트 데이터
|
||||||
|
test_data = {
|
||||||
|
"store_name": "더블샷 카페",
|
||||||
|
"business_type": "카페",
|
||||||
|
"location": "서울시 강남구 역삼동",
|
||||||
|
"seat_count": 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 호출
|
||||||
|
url = "http://localhost:5001/api/v1/generate-marketing-tip"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer dummy-key"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=test_data, headers=headers)
|
||||||
|
|
||||||
|
print(f"Status Code: {response.status_code}")
|
||||||
|
print(f"Response: {json.dumps(response.json(), ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✅ 테스트 성공!")
|
||||||
|
else:
|
||||||
|
print("❌ 테스트 실패!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 테스트 오류: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_marketing_tip_api()
|
||||||
1
smarketing-ai/utils/__init__.py
Normal file
1
smarketing-ai/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
237
smarketing-ai/utils/ai_client.py
Normal file
237
smarketing-ai/utils/ai_client.py
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
from utils.blob_storage import BlobStorageClient
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
# Blob Storage 클라이언트 초기화
|
||||||
|
self.blob_client = BlobStorageClient()
|
||||||
|
|
||||||
|
# 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 = "1024x1536") -> str:
|
||||||
|
"""
|
||||||
|
gpt를 사용하여 이미지 생성
|
||||||
|
Args:
|
||||||
|
prompt: 이미지 생성 프롬프트
|
||||||
|
size: 이미지 크기 (1024x1536)
|
||||||
|
Returns:
|
||||||
|
Azure Blob Storage에 저장된 이미지 URL
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.openai_client:
|
||||||
|
raise Exception("OpenAI API 키가 설정되지 않았습니다.")
|
||||||
|
|
||||||
|
response = self.openai_client.images.generate(
|
||||||
|
model="gpt-image-1",
|
||||||
|
prompt=prompt,
|
||||||
|
size=size,
|
||||||
|
n=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# base64 이미지 데이터 추출
|
||||||
|
b64_data = response.data[0].b64_json
|
||||||
|
image_data = base64.b64decode(b64_data)
|
||||||
|
|
||||||
|
# Azure Blob Storage에 업로드
|
||||||
|
blob_url = self.blob_client.upload_image(image_data, 'png')
|
||||||
|
|
||||||
|
print(f"✅ 이미지 생성 및 업로드 완료: {blob_url}")
|
||||||
|
return blob_url
|
||||||
|
|
||||||
|
except Exception as 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 "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."
|
||||||
117
smarketing-ai/utils/blob_storage.py
Normal file
117
smarketing-ai/utils/blob_storage.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Azure Blob Storage 유틸리티
|
||||||
|
이미지 업로드 및 URL 생성 기능 제공
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from azure.storage.blob import BlobServiceClient, ContentSettings
|
||||||
|
from config.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class BlobStorageClient:
|
||||||
|
"""Azure Blob Storage 클라이언트 클래스"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Blob Storage 클라이언트 초기화"""
|
||||||
|
self.account_name = Config.AZURE_STORAGE_ACCOUNT_NAME
|
||||||
|
self.account_key = Config.AZURE_STORAGE_ACCOUNT_KEY
|
||||||
|
self.container_name = Config.AZURE_STORAGE_CONTAINER_NAME
|
||||||
|
|
||||||
|
if not self.account_key:
|
||||||
|
raise ValueError("Azure Storage Account Key가 설정되지 않았습니다.")
|
||||||
|
|
||||||
|
# Connection String 생성
|
||||||
|
connection_string = f"DefaultEndpointsProtocol=https;AccountName={self.account_name};AccountKey={self.account_key};EndpointSuffix=core.windows.net"
|
||||||
|
|
||||||
|
# Blob Service Client 초기화
|
||||||
|
self.blob_service_client = BlobServiceClient.from_connection_string(connection_string)
|
||||||
|
|
||||||
|
def upload_image(self, image_data: bytes, file_extension: str = 'png') -> str:
|
||||||
|
"""
|
||||||
|
이미지를 Blob Storage에 업로드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_data: 업로드할 이미지 바이트 데이터
|
||||||
|
file_extension: 파일 확장자 (기본값: 'png')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
업로드된 이미지의 Public URL
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 파일명 생성: poster_YYYYMMDDHHMMSS.png
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
blob_name = f"poster_{timestamp}.{file_extension}"
|
||||||
|
|
||||||
|
# Content Type 설정
|
||||||
|
content_settings = ContentSettings(content_type=f'image/{file_extension}')
|
||||||
|
|
||||||
|
# Blob 업로드
|
||||||
|
blob_client = self.blob_service_client.get_blob_client(
|
||||||
|
container=self.container_name,
|
||||||
|
blob=blob_name
|
||||||
|
)
|
||||||
|
|
||||||
|
blob_client.upload_blob(
|
||||||
|
image_data,
|
||||||
|
content_settings=content_settings,
|
||||||
|
overwrite=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Public URL 생성
|
||||||
|
blob_url = f"https://{self.account_name}.blob.core.windows.net/{self.container_name}/{blob_name}"
|
||||||
|
|
||||||
|
print(f"✅ 이미지 업로드 완료: {blob_url}")
|
||||||
|
return blob_url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Blob Storage 업로드 실패: {str(e)}")
|
||||||
|
raise Exception(f"이미지 업로드 실패: {str(e)}")
|
||||||
|
|
||||||
|
def upload_file(self, file_path: str) -> str:
|
||||||
|
"""
|
||||||
|
로컬 파일을 Blob Storage에 업로드
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: 업로드할 로컬 파일 경로
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
업로드된 파일의 Public URL
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 파일 확장자 추출
|
||||||
|
file_extension = os.path.splitext(file_path)[1][1:].lower()
|
||||||
|
|
||||||
|
# 파일 읽기
|
||||||
|
with open(file_path, 'rb') as file:
|
||||||
|
file_data = file.read()
|
||||||
|
|
||||||
|
# 업로드
|
||||||
|
return self.upload_image(file_data, file_extension)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 파일 업로드 실패: {str(e)}")
|
||||||
|
raise Exception(f"파일 업로드 실패: {str(e)}")
|
||||||
|
|
||||||
|
def delete_blob(self, blob_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Blob 삭제
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blob_name: 삭제할 Blob 이름
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
삭제 성공 여부
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
blob_client = self.blob_service_client.get_blob_client(
|
||||||
|
container=self.container_name,
|
||||||
|
blob=blob_name
|
||||||
|
)
|
||||||
|
blob_client.delete_blob()
|
||||||
|
print(f"✅ Blob 삭제 완료: {blob_name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Blob 삭제 실패: {str(e)}")
|
||||||
|
return False
|
||||||
166
smarketing-ai/utils/image_processor.py
Normal file
166
smarketing-ai/utils/image_processor.py
Normal file
@ -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
|
||||||
37
smarketing-java/.gitignore
vendored
Normal file
37
smarketing-java/.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
HELP.md
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### STS ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
4
smarketing-java/ai-recommend/build.gradle
Normal file
4
smarketing-java/ai-recommend/build.gradle
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
dependencies {
|
||||||
|
implementation project(':common')
|
||||||
|
runtimeOnly 'com.mysql:mysql-connector-j'
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
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.MenuData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
|
||||||
|
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.presentation.dto.MarketingTipResponse;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional
|
||||||
|
public class MarketingTipService implements MarketingTipUseCase {
|
||||||
|
|
||||||
|
private final MarketingTipRepository marketingTipRepository;
|
||||||
|
private final StoreDataProvider storeDataProvider;
|
||||||
|
private final AiTipGenerator aiTipGenerator;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MarketingTipResponse provideMarketingTip() {
|
||||||
|
String userId = getCurrentUserId();
|
||||||
|
log.info("마케팅 팁 제공: userId={}", userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 사용자의 매장 정보 조회
|
||||||
|
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
|
||||||
|
|
||||||
|
// 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인
|
||||||
|
Optional<MarketingTip> recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId());
|
||||||
|
|
||||||
|
if (recentTip.isPresent()) {
|
||||||
|
log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue());
|
||||||
|
log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent());
|
||||||
|
return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 1시간 이내 팁이 없으면 새로 생성
|
||||||
|
log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId());
|
||||||
|
MarketingTip newTip = createNewMarketingTip(storeWithMenuData);
|
||||||
|
return convertToResponse(newTip, storeWithMenuData.getStoreData(), false);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e);
|
||||||
|
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB에서 1시간 이내 생성된 마케팅 팁 조회
|
||||||
|
*/
|
||||||
|
private Optional<MarketingTip> findRecentMarketingTip(Long storeId) {
|
||||||
|
log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId);
|
||||||
|
|
||||||
|
// 최근 생성된 팁 1개 조회
|
||||||
|
Pageable pageable = PageRequest.of(0, 1);
|
||||||
|
Page<MarketingTip> recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
|
||||||
|
|
||||||
|
if (recentTips.isEmpty()) {
|
||||||
|
log.debug("매장의 마케팅 팁이 존재하지 않음: storeId={}", storeId);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
MarketingTip mostRecentTip = recentTips.getContent().get(0);
|
||||||
|
LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
|
||||||
|
|
||||||
|
// 1시간 이내에 생성된 팁인지 확인
|
||||||
|
if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) {
|
||||||
|
log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}",
|
||||||
|
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
|
||||||
|
return Optional.of(mostRecentTip);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}",
|
||||||
|
mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt());
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새로운 마케팅 팁 생성
|
||||||
|
*/
|
||||||
|
private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) {
|
||||||
|
log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName());
|
||||||
|
|
||||||
|
// AI 서비스로 팁 생성
|
||||||
|
String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData);
|
||||||
|
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
|
||||||
|
|
||||||
|
// 도메인 객체 생성 및 저장
|
||||||
|
MarketingTip marketingTip = MarketingTip.builder()
|
||||||
|
.storeId(storeWithMenuData.getStoreData().getStoreId())
|
||||||
|
.tipContent(aiGeneratedTip)
|
||||||
|
.storeWithMenuData(storeWithMenuData)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
|
||||||
|
log.info("새로운 마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
|
||||||
|
log.info("새로운 마케팅 팁 저장 완료: savedTip.getTipContent()={}", savedTip.getTipContent());
|
||||||
|
|
||||||
|
return savedTip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함)
|
||||||
|
*/
|
||||||
|
private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) {
|
||||||
|
String tipSummary = generateTipSummary(marketingTip.getTipContent());
|
||||||
|
|
||||||
|
return MarketingTipResponse.builder()
|
||||||
|
.tipId(marketingTip.getId().getValue())
|
||||||
|
.tipSummary(tipSummary)
|
||||||
|
.tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함
|
||||||
|
.storeInfo(MarketingTipResponse.StoreInfo.builder()
|
||||||
|
.storeName(storeData.getStoreName())
|
||||||
|
.businessType(storeData.getBusinessType())
|
||||||
|
.location(storeData.getLocation())
|
||||||
|
.build())
|
||||||
|
.createdAt(marketingTip.getCreatedAt())
|
||||||
|
.updatedAt(marketingTip.getUpdatedAt())
|
||||||
|
.isRecentlyCreated(isRecentlyCreated)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장)
|
||||||
|
*/
|
||||||
|
private String generateTipSummary(String fullContent) {
|
||||||
|
if (fullContent == null || fullContent.trim().isEmpty()) {
|
||||||
|
return "마케팅 팁이 생성되었습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 문장으로 요약 (마침표 기준)
|
||||||
|
String[] sentences = fullContent.split("[.!?]");
|
||||||
|
String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent;
|
||||||
|
|
||||||
|
// 50자 제한
|
||||||
|
if (firstSentence.length() > 50) {
|
||||||
|
return firstSentence.substring(0, 47) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstSentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인된 사용자 ID 조회
|
||||||
|
*/
|
||||||
|
private String getCurrentUserId() {
|
||||||
|
return SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.won.smarketing.recommend.application.usecase;
|
||||||
|
|
||||||
|
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
|
||||||
|
|
||||||
|
public interface MarketingTipUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁 제공
|
||||||
|
* 1시간 이내 팁이 있으면 기존 것 사용, 없으면 새로 생성
|
||||||
|
*/
|
||||||
|
MarketingTipResponse provideMarketingTip();
|
||||||
|
}
|
||||||
@ -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 캐시 사용
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
}
|
||||||
@ -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, 10000)
|
||||||
|
.responseTimeout(Duration.ofMillis(30000));
|
||||||
|
|
||||||
|
return WebClient.builder()
|
||||||
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.won.smarketing.recommend.domain.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.springframework.cglib.core.Local;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁 도메인 모델 (날씨 정보 제거)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class MarketingTip {
|
||||||
|
|
||||||
|
private TipId id;
|
||||||
|
private Long storeId;
|
||||||
|
private String tipSummary;
|
||||||
|
private String tipContent;
|
||||||
|
private StoreWithMenuData storeWithMenuData;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) {
|
||||||
|
return MarketingTip.builder()
|
||||||
|
.storeId(storeId)
|
||||||
|
.tipContent(tipContent)
|
||||||
|
.storeWithMenuData(storeWithMenuData)
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
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 MenuData {
|
||||||
|
private Long menuId;
|
||||||
|
private String menuName;
|
||||||
|
private String category;
|
||||||
|
private Integer price;
|
||||||
|
private String description;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
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 Long storeId;
|
||||||
|
private String storeName;
|
||||||
|
private String businessType;
|
||||||
|
private String location;
|
||||||
|
private String description;
|
||||||
|
private Integer seatCount;
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.won.smarketing.recommend.domain.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class StoreWithMenuData {
|
||||||
|
private StoreData storeData;
|
||||||
|
private List<MenuData> menuDataList;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<MarketingTip> findById(Long tipId);
|
||||||
|
|
||||||
|
Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable);
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package com.won.smarketing.recommend.domain.service;
|
||||||
|
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 팁 생성 도메인 서비스 인터페이스 (단순화)
|
||||||
|
*/
|
||||||
|
public interface AiTipGenerator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Python AI 서비스를 통한 마케팅 팁 생성
|
||||||
|
*
|
||||||
|
* @param storeWithMenuData 매장 및 메뉴 정보
|
||||||
|
* @return AI가 생성한 마케팅 팁
|
||||||
|
*/
|
||||||
|
String generateTip(StoreWithMenuData storeWithMenuData);
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package com.won.smarketing.recommend.domain.service;
|
||||||
|
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 데이터 제공 도메인 서비스 인터페이스
|
||||||
|
*/
|
||||||
|
public interface StoreDataProvider {
|
||||||
|
|
||||||
|
StoreWithMenuData getStoreWithMenuData(String userId);
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
package com.won.smarketing.recommend.infrastructure.external;
|
||||||
|
|
||||||
|
import com.won.smarketing.recommend.domain.model.MenuData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
|
||||||
|
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
|
||||||
|
import lombok.Getter;
|
||||||
|
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.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(StoreWithMenuData storeWithMenuData) {
|
||||||
|
try {
|
||||||
|
log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName());
|
||||||
|
return callPythonAiService(storeWithMenuData);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
|
||||||
|
return createFallbackTip(storeWithMenuData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String callPythonAiService(StoreWithMenuData storeWithMenuData) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
StoreData storeData = storeWithMenuData.getStoreData();
|
||||||
|
List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
|
||||||
|
|
||||||
|
// 메뉴 데이터를 Map 형태로 변환
|
||||||
|
List<Map<String, Object>> menuList = menuDataList.stream()
|
||||||
|
.map(menu -> {
|
||||||
|
Map<String, Object> menuMap = new HashMap<>();
|
||||||
|
menuMap.put("menu_id", menu.getMenuId());
|
||||||
|
menuMap.put("menu_name", menu.getMenuName());
|
||||||
|
menuMap.put("category", menu.getCategory());
|
||||||
|
menuMap.put("price", menu.getPrice());
|
||||||
|
menuMap.put("description", menu.getDescription());
|
||||||
|
return menuMap;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보)
|
||||||
|
Map<String, Object> requestData = new HashMap<>();
|
||||||
|
requestData.put("store_name", storeData.getStoreName());
|
||||||
|
requestData.put("business_type", storeData.getBusinessType());
|
||||||
|
requestData.put("location", storeData.getLocation());
|
||||||
|
requestData.put("seat_count", storeData.getSeatCount());
|
||||||
|
requestData.put("menu_list", menuList);
|
||||||
|
|
||||||
|
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(storeWithMenuData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용)
|
||||||
|
*/
|
||||||
|
private String createFallbackTip(StoreWithMenuData storeWithMenuData) {
|
||||||
|
String businessType = storeWithMenuData.getStoreData().getBusinessType();
|
||||||
|
String storeName = storeWithMenuData.getStoreData().getStoreName();
|
||||||
|
String location = storeWithMenuData.getStoreData().getLocation();
|
||||||
|
|
||||||
|
// 업종별 기본 팁 생성
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
private static class PythonAiResponse {
|
||||||
|
private String tip;
|
||||||
|
private String status;
|
||||||
|
private String message;
|
||||||
|
private LocalDateTime generatedTip;
|
||||||
|
private String businessType;
|
||||||
|
private String aiModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,311 @@
|
|||||||
|
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.MenuData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreData;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
|
||||||
|
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientException;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장 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;
|
||||||
|
|
||||||
|
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||||
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
public StoreWithMenuData getStoreWithMenuData(String userId) {
|
||||||
|
log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 매장 정보와 메뉴 정보를 병렬로 조회
|
||||||
|
StoreData storeData = getStoreDataByUserId(userId);
|
||||||
|
List<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
|
||||||
|
|
||||||
|
StoreWithMenuData result = StoreWithMenuData.builder()
|
||||||
|
.storeData(storeData)
|
||||||
|
.menuDataList(menuDataList)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
|
||||||
|
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
|
||||||
|
|
||||||
|
// 실패 시 Mock 데이터 반환
|
||||||
|
return StoreWithMenuData.builder()
|
||||||
|
.storeData(createMockStoreData(userId))
|
||||||
|
.menuDataList(createMockMenuData(6L))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreData getStoreDataByUserId(String userId) {
|
||||||
|
try {
|
||||||
|
log.debug("매장 정보 실시간 조회: userId={}", userId);
|
||||||
|
return callStoreServiceByUserId(userId);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
|
||||||
|
return createMockStoreData(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public List<MenuData> getMenusByStoreId(Long storeId) {
|
||||||
|
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return callMenuService(storeId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
|
||||||
|
return createMockMenuData(storeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoreData callStoreServiceByUserId(String userId) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
StoreApiResponse response = webClient
|
||||||
|
.get()
|
||||||
|
.uri(storeServiceBaseUrl + "/api/store")
|
||||||
|
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(StoreApiResponse.class)
|
||||||
|
.timeout(Duration.ofMillis(timeout))
|
||||||
|
.block();
|
||||||
|
|
||||||
|
log.info("response : {}", response.getData().getStoreName());
|
||||||
|
log.info("response : {}", response.getData().getStoreId());
|
||||||
|
|
||||||
|
if (response != null && response.getData() != null) {
|
||||||
|
StoreApiResponse.StoreInfo storeInfo = response.getData();
|
||||||
|
return StoreData.builder()
|
||||||
|
.storeId(storeInfo.getStoreId())
|
||||||
|
.storeName(storeInfo.getStoreName())
|
||||||
|
.businessType(storeInfo.getBusinessType())
|
||||||
|
.location(storeInfo.getAddress())
|
||||||
|
.description(storeInfo.getDescription())
|
||||||
|
.seatCount(storeInfo.getSeatCount())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (e.getStatusCode().value() == 404) {
|
||||||
|
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
|
||||||
|
}
|
||||||
|
log.error("매장 서비스 호출 실패: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMockStoreData(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCurrentJwtToken() {
|
||||||
|
try {
|
||||||
|
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||||
|
|
||||||
|
if (attributes == null) {
|
||||||
|
log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpServletRequest request = attributes.getRequest();
|
||||||
|
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
|
||||||
|
String token = bearerToken.substring(BEARER_PREFIX.length());
|
||||||
|
log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length())));
|
||||||
|
return token;
|
||||||
|
} else {
|
||||||
|
log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MenuData> callMenuService(Long storeId) {
|
||||||
|
try {
|
||||||
|
MenuApiResponse response = webClient
|
||||||
|
.get()
|
||||||
|
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(MenuApiResponse.class)
|
||||||
|
.timeout(Duration.ofMillis(timeout))
|
||||||
|
.block();
|
||||||
|
|
||||||
|
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
|
||||||
|
List<MenuData> menuDataList = response.getData().stream()
|
||||||
|
.map(this::toMenuData)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
|
||||||
|
return menuDataList;
|
||||||
|
}
|
||||||
|
} catch (WebClientResponseException e) {
|
||||||
|
if (e.getStatusCode().value() == 404) {
|
||||||
|
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
|
||||||
|
} catch (WebClientException e) {
|
||||||
|
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMockMenuData(storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MenuResponse를 MenuData로 변환
|
||||||
|
*/
|
||||||
|
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
|
||||||
|
return MenuData.builder()
|
||||||
|
.menuId(menuInfo.getMenuId())
|
||||||
|
.menuName(menuInfo.getMenuName())
|
||||||
|
.category(menuInfo.getCategory())
|
||||||
|
.price(menuInfo.getPrice())
|
||||||
|
.description(menuInfo.getDescription())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoreData createMockStoreData(String userId) {
|
||||||
|
return StoreData.builder()
|
||||||
|
.storeName("테스트 카페 " + userId)
|
||||||
|
.businessType("카페")
|
||||||
|
.location("서울시 강남구")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MenuData> createMockMenuData(Long storeId) {
|
||||||
|
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
|
||||||
|
|
||||||
|
return List.of(
|
||||||
|
MenuData.builder()
|
||||||
|
.menuId(1L)
|
||||||
|
.menuName("아메리카노")
|
||||||
|
.category("음료")
|
||||||
|
.price(4000)
|
||||||
|
.description("깊고 진한 맛의 아메리카노")
|
||||||
|
.build(),
|
||||||
|
MenuData.builder()
|
||||||
|
.menuId(2L)
|
||||||
|
.menuName("카페라떼")
|
||||||
|
.category("음료")
|
||||||
|
.price(4500)
|
||||||
|
.description("부드러운 우유 거품이 올라간 카페라떼")
|
||||||
|
.build(),
|
||||||
|
MenuData.builder()
|
||||||
|
.menuId(3L)
|
||||||
|
.menuName("치즈케이크")
|
||||||
|
.category("디저트")
|
||||||
|
.price(6000)
|
||||||
|
.description("진한 치즈 맛의 수제 케이크")
|
||||||
|
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
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; }
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
static class StoreInfo {
|
||||||
|
private Long storeId;
|
||||||
|
private String storeName;
|
||||||
|
private String businessType;
|
||||||
|
private String address;
|
||||||
|
private String description;
|
||||||
|
private Integer seatCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menu API 응답 DTO (새로 추가)
|
||||||
|
*/
|
||||||
|
private static class MenuApiResponse {
|
||||||
|
private List<MenuInfo> data;
|
||||||
|
private String message;
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
public List<MenuInfo> getData() { return data; }
|
||||||
|
public void setData(List<MenuInfo> data) { this.data = data; }
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public void setMessage(String message) { this.message = message; }
|
||||||
|
public boolean isSuccess() { return success; }
|
||||||
|
public void setSuccess(boolean success) { this.success = success; }
|
||||||
|
|
||||||
|
public static class MenuInfo {
|
||||||
|
private Long menuId;
|
||||||
|
private String menuName;
|
||||||
|
private String category;
|
||||||
|
private Integer price;
|
||||||
|
private String description;
|
||||||
|
private String image;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Long getMenuId() { return menuId; }
|
||||||
|
public void setMenuId(Long menuId) { this.menuId = menuId; }
|
||||||
|
public String getMenuName() { return menuName; }
|
||||||
|
public void setMenuName(String menuName) { this.menuName = menuName; }
|
||||||
|
public String getCategory() { return category; }
|
||||||
|
public void setCategory(String category) { this.category = category; }
|
||||||
|
public Integer getPrice() { return price; }
|
||||||
|
public void setPrice(Integer price) { this.price = price; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public String getImage() { return image; }
|
||||||
|
public void setImage(String image) { this.image = image; }
|
||||||
|
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||||
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
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.annotation.LastModifiedDate;
|
||||||
|
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)
|
||||||
|
@Column(name = "tip_id", nullable = false)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "user_id", nullable = false, length = 50)
|
||||||
|
private String userId;
|
||||||
|
|
||||||
|
@Column(name = "store_id", nullable = false)
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
@Column(name = "tip_summary")
|
||||||
|
private String tipSummary;
|
||||||
|
|
||||||
|
@Lob
|
||||||
|
@Column(name = "tip_content", nullable = false, columnDefinition = "TEXT")
|
||||||
|
private String tipContent;
|
||||||
|
|
||||||
|
@Column(name = "ai_model")
|
||||||
|
private String aiModel;
|
||||||
|
|
||||||
|
@CreatedDate
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@LastModifiedDate
|
||||||
|
@Column(name = "updated_at")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public static MarketingTipEntity fromDomain(MarketingTip marketingTip, String userId) {
|
||||||
|
return MarketingTipEntity.builder()
|
||||||
|
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
|
||||||
|
.userId(userId)
|
||||||
|
.storeId(marketingTip.getStoreId())
|
||||||
|
.tipContent(marketingTip.getTipContent())
|
||||||
|
.tipSummary(marketingTip.getTipSummary())
|
||||||
|
.createdAt(marketingTip.getCreatedAt())
|
||||||
|
.updatedAt(marketingTip.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public MarketingTip toDomain(StoreData storeData) {
|
||||||
|
return MarketingTip.builder()
|
||||||
|
.id(this.id != null ? TipId.of(this.id) : null)
|
||||||
|
.storeId(this.storeId)
|
||||||
|
.tipSummary(this.tipSummary)
|
||||||
|
.tipContent(this.tipContent)
|
||||||
|
.createdAt(this.createdAt)
|
||||||
|
.updatedAt(this.updatedAt)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁 JPA 레포지토리
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장별 마케팅 팁 조회 (기존 - storeId 기반)
|
||||||
|
*/
|
||||||
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
|
||||||
|
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자별 마케팅 팁 조회 (새로 추가 - userId 기반)
|
||||||
|
*/
|
||||||
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC")
|
||||||
|
Page<MarketingTipEntity> findByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 가장 최근 마케팅 팁 조회
|
||||||
|
*/
|
||||||
|
@Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC LIMIT 1")
|
||||||
|
Optional<MarketingTipEntity> findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 팁이 해당 사용자의 것인지 확인
|
||||||
|
*/
|
||||||
|
boolean existsByIdAndUserId(Long id, String userId);
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
package com.won.smarketing.recommend.infrastructure.persistence;
|
||||||
|
|
||||||
|
import com.won.smarketing.recommend.domain.model.MarketingTip;
|
||||||
|
import com.won.smarketing.recommend.domain.model.StoreWithMenuData;
|
||||||
|
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
|
||||||
|
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
|
||||||
|
|
||||||
|
private final MarketingTipJpaRepository jpaRepository;
|
||||||
|
private final StoreDataProvider storeDataProvider;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MarketingTip save(MarketingTip marketingTip) {
|
||||||
|
String userId = getCurrentUserId();
|
||||||
|
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId);
|
||||||
|
MarketingTipEntity savedEntity = jpaRepository.save(entity);
|
||||||
|
|
||||||
|
// Store 정보는 다시 조회해서 Domain에 설정
|
||||||
|
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
|
||||||
|
return savedEntity.toDomain(storeWithMenuData.getStoreData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<MarketingTip> findById(Long tipId) {
|
||||||
|
return jpaRepository.findById(tipId)
|
||||||
|
.map(entity -> {
|
||||||
|
// Store 정보를 API로 조회
|
||||||
|
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId());
|
||||||
|
return entity.toDomain(storeWithMenuData.getStoreData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
|
||||||
|
// 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회
|
||||||
|
String userId = getCurrentUserId();
|
||||||
|
return findByUserIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자별 마케팅 팁 조회 (새로 추가)
|
||||||
|
*/
|
||||||
|
public Page<MarketingTip> findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) {
|
||||||
|
Page<MarketingTipEntity> entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
|
||||||
|
// Store 정보는 한 번만 조회 (같은 userId이므로)
|
||||||
|
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
|
||||||
|
|
||||||
|
return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 가장 최근 마케팅 팁 조회
|
||||||
|
*/
|
||||||
|
public Optional<MarketingTip> findMostRecentByUserId(String userId) {
|
||||||
|
return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId)
|
||||||
|
.map(entity -> {
|
||||||
|
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
|
||||||
|
return entity.toDomain(storeWithMenuData.getStoreData());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 팁이 해당 사용자의 것인지 확인
|
||||||
|
*/
|
||||||
|
public boolean isOwnedByUser(Long tipId, String userId) {
|
||||||
|
return jpaRepository.existsByIdAndUserId(tipId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 로그인된 사용자 ID 조회
|
||||||
|
*/
|
||||||
|
private String getCurrentUserId() {
|
||||||
|
return SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
//package com.won.smarketing.recommend.presentation.controller;
|
||||||
|
//
|
||||||
|
//import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
//import org.springframework.web.bind.annotation.RestController;
|
||||||
|
//
|
||||||
|
//import java.time.LocalDateTime;
|
||||||
|
//import java.util.Map;
|
||||||
|
//
|
||||||
|
///**
|
||||||
|
// * 헬스체크 컨트롤러
|
||||||
|
// */
|
||||||
|
//@RestController
|
||||||
|
//public class HealthController {
|
||||||
|
//
|
||||||
|
// @GetMapping("/health")
|
||||||
|
// public Map<String, Object> health() {
|
||||||
|
// return Map.of(
|
||||||
|
// "status", "UP",
|
||||||
|
// "service", "ai-recommend-service",
|
||||||
|
// "timestamp", LocalDateTime.now(),
|
||||||
|
// "message", "AI 추천 서비스가 정상 동작 중입니다.",
|
||||||
|
// "features", Map.of(
|
||||||
|
// "store_integration", "매장 서비스 연동",
|
||||||
|
// "python_ai_integration", "Python AI 서비스 연동",
|
||||||
|
// "fallback_support", "Fallback 팁 생성 지원"
|
||||||
|
// )
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// } catch (Exception e) {
|
||||||
|
// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
|
||||||
|
// return createMockStoreData(storeId);
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
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.MarketingTipResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 마케팅 추천 컨트롤러 (단일 API)
|
||||||
|
*/
|
||||||
|
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/recommendations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RecommendationController {
|
||||||
|
|
||||||
|
private final MarketingTipUseCase marketingTipUseCase;
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "마케팅 팁 조회/생성",
|
||||||
|
description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성"
|
||||||
|
)
|
||||||
|
@PostMapping("/marketing-tips")
|
||||||
|
public ResponseEntity<ApiResponse<MarketingTipResponse>> provideMarketingTip() {
|
||||||
|
|
||||||
|
log.info("마케팅 팁 제공 요청");
|
||||||
|
|
||||||
|
MarketingTipResponse response = marketingTipUseCase.provideMarketingTip();
|
||||||
|
|
||||||
|
log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId());
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마케팅 팁 응답 DTO (요약 + 상세 통합)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "마케팅 팁 응답")
|
||||||
|
public class MarketingTipResponse {
|
||||||
|
|
||||||
|
@Schema(description = "팁 ID", example = "1")
|
||||||
|
private Long tipId;
|
||||||
|
|
||||||
|
@Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!")
|
||||||
|
private String tipSummary;
|
||||||
|
|
||||||
|
@Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...")
|
||||||
|
private String tipContent;
|
||||||
|
|
||||||
|
@Schema(description = "매장 정보")
|
||||||
|
private StoreInfo storeInfo;
|
||||||
|
|
||||||
|
@Schema(description = "생성 시간", example = "2025-06-13T14:30:00")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Schema(description = "수정 시간", example = "2025-06-13T14:30:00")
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Schema(description = "1시간 이내 생성 여부", example = "true")
|
||||||
|
private boolean isRecentlyCreated;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "매장 정보")
|
||||||
|
public static class StoreInfo {
|
||||||
|
@Schema(description = "매장명", example = "민코의 카페")
|
||||||
|
private String storeName;
|
||||||
|
|
||||||
|
@Schema(description = "업종", example = "카페")
|
||||||
|
private String businessType;
|
||||||
|
|
||||||
|
@Schema(description = "위치", example = "서울시 강남구 테헤란로 123")
|
||||||
|
private String location;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8084}
|
||||||
|
servlet:
|
||||||
|
context-path: /
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: ai-recommend-service
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB}
|
||||||
|
username: ${POSTGRES_USER:postgres}
|
||||||
|
password: ${POSTGRES_PASSWORD:postgres}
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: ${JPA_DDL_AUTO:create-drop}
|
||||||
|
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:}
|
||||||
|
|
||||||
|
external:
|
||||||
|
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:5001}
|
||||||
|
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
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
|
||||||
|
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
|
||||||
|
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
|
||||||
55
smarketing-java/build.gradle
Normal file
55
smarketing-java/build.gradle
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'org.springframework.boot' version '3.2.0'
|
||||||
|
id 'io.spring.dependency-management' version '1.1.4'
|
||||||
|
}
|
||||||
|
// 루트 프로젝트에서는 bootJar 태스크 비활성화
|
||||||
|
bootJar {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
group = 'com.won.smarketing'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply plugin: 'java'
|
||||||
|
apply plugin: 'org.springframework.boot'
|
||||||
|
apply plugin: 'io.spring.dependency-management'
|
||||||
|
|
||||||
|
configurations {
|
||||||
|
compileOnly {
|
||||||
|
extendsFrom annotationProcessor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
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-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'
|
||||||
|
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
|
||||||
|
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
|
||||||
|
compileOnly 'org.projectlombok:lombok'
|
||||||
|
annotationProcessor 'org.projectlombok:lombok'
|
||||||
|
|
||||||
|
// PostgreSQL (운영용)
|
||||||
|
runtimeOnly 'org.postgresql:postgresql:42.7.1'
|
||||||
|
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named('test') {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
23
smarketing-java/common/build.gradle
Normal file
23
smarketing-java/common/build.gradle
Normal file
@ -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'
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
package com.won.smarketing.common.config;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 설정 클래스
|
||||||
|
* Redis 연결 및 템플릿 설정
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.host}")
|
||||||
|
private String redisHost;
|
||||||
|
|
||||||
|
@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 연결 팩토리 설정
|
||||||
|
*
|
||||||
|
* @return Redis 연결 팩토리
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RedisConnectionFactory redisConnectionFactory() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 템플릿 설정
|
||||||
|
*
|
||||||
|
* @return Redis 템플릿
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public RedisTemplate<String, String> redisTemplate() {
|
||||||
|
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
|
||||||
|
redisTemplate.setConnectionFactory(redisConnectionFactory());
|
||||||
|
redisTemplate.setKeySerializer(new StringRedisSerializer());
|
||||||
|
redisTemplate.setValueSerializer(new StringRedisSerializer());
|
||||||
|
return redisTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
package com.won.smarketing.common.config;
|
||||||
|
|
||||||
|
import com.won.smarketing.common.security.JwtAuthenticationFilter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 설정 클래스
|
||||||
|
* JWT 기반 인증 및 CORS 설정
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 필터 체인 설정
|
||||||
|
*
|
||||||
|
* @param http HttpSecurity 객체
|
||||||
|
* @return SecurityFilterChain
|
||||||
|
* @throws Exception 예외
|
||||||
|
*/
|
||||||
|
@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/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 CorsConfigurationSource
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표준 API 응답 DTO
|
||||||
|
* 모든 API 응답에 사용되는 공통 형식
|
||||||
|
*
|
||||||
|
* @param <T> 응답 데이터 타입
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "API 응답")
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
@Schema(description = "응답 상태 코드", example = "200")
|
||||||
|
private int status;
|
||||||
|
|
||||||
|
@Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Schema(description = "응답 데이터")
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성 (데이터 포함)
|
||||||
|
*
|
||||||
|
* @param data 응답 데이터
|
||||||
|
* @param <T> 데이터 타입
|
||||||
|
* @return 성공 응답
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.status(200)
|
||||||
|
.message("요청이 성공적으로 처리되었습니다.")
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성 (데이터 및 메시지 포함)
|
||||||
|
*
|
||||||
|
* @param data 응답 데이터
|
||||||
|
* @param message 응답 메시지
|
||||||
|
* @param <T> 데이터 타입
|
||||||
|
* @return 성공 응답
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(T data, String message) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.status(200)
|
||||||
|
.message(message)
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오류 응답 생성
|
||||||
|
*
|
||||||
|
* @param status 오류 상태 코드
|
||||||
|
* @param message 오류 메시지
|
||||||
|
* @param <T> 데이터 타입
|
||||||
|
* @return 오류 응답
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(int status, String message) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.status(status)
|
||||||
|
.message(message)
|
||||||
|
.data(null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <T> 응답 데이터 타입
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@Schema(description = "페이징 응답")
|
||||||
|
public class PageResponse<T> {
|
||||||
|
|
||||||
|
@Schema(description = "페이지 컨텐츠", example = "[...]")
|
||||||
|
private List<T> 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 <T> 데이터 타입
|
||||||
|
* @return 페이징 응답
|
||||||
|
*/
|
||||||
|
public static <T> PageResponse<T> of(List<T> content, int pageNumber, int pageSize, long totalElements) {
|
||||||
|
int totalPages = (int) Math.ceil((double) totalElements / pageSize);
|
||||||
|
|
||||||
|
return PageResponse.<T>builder()
|
||||||
|
.content(content)
|
||||||
|
.pageNumber(pageNumber)
|
||||||
|
.pageSize(pageSize)
|
||||||
|
.totalElements(totalElements)
|
||||||
|
.totalPages(totalPages)
|
||||||
|
.first(pageNumber == 0)
|
||||||
|
.last(pageNumber >= totalPages - 1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.won.smarketing.common.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈니스 로직 예외
|
||||||
|
* 애플리케이션 내 비즈니스 규칙 위반 시 발생하는 예외
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private final ErrorCode errorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈니스 예외 생성자
|
||||||
|
*
|
||||||
|
* @param errorCode 오류 코드
|
||||||
|
*/
|
||||||
|
public BusinessException(ErrorCode errorCode) {
|
||||||
|
super(errorCode.getMessage());
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈니스 예외 생성자 (추가 메시지 포함)
|
||||||
|
*
|
||||||
|
* @param errorCode 오류 코드
|
||||||
|
* @param message 추가 메시지
|
||||||
|
*/
|
||||||
|
public BusinessException(ErrorCode errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.won.smarketing.common.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 애플리케이션 오류 코드 정의
|
||||||
|
* 각 오류 상황에 대한 코드, HTTP 상태, 메시지 정의
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public enum ErrorCode {
|
||||||
|
|
||||||
|
// 회원 관련 오류
|
||||||
|
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없습니다."),
|
||||||
|
DUPLICATE_MEMBER_ID(HttpStatus.BAD_REQUEST, "M002", "이미 사용 중인 사용자 ID입니다."),
|
||||||
|
DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "M003", "이미 사용 중인 이메일입니다."),
|
||||||
|
DUPLICATE_BUSINESS_NUMBER(HttpStatus.BAD_REQUEST, "M004", "이미 등록된 사업자 번호입니다."),
|
||||||
|
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "M005", "잘못된 패스워드입니다."),
|
||||||
|
INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "M006", "유효하지 않은 토큰입니다."),
|
||||||
|
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "M007", "만료된 토큰입니다."),
|
||||||
|
|
||||||
|
// 매장 관련 오류
|
||||||
|
STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "매장을 찾을 수 없습니다."),
|
||||||
|
STORE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "S002", "이미 등록된 매장이 있습니다."),
|
||||||
|
MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "S003", "메뉴를 찾을 수 없습니다."),
|
||||||
|
|
||||||
|
// 마케팅 콘텐츠 관련 오류
|
||||||
|
CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C001", "콘텐츠를 찾을 수 없습니다."),
|
||||||
|
CONTENT_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "콘텐츠 생성에 실패했습니다."),
|
||||||
|
AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "C003", "AI 서비스를 사용할 수 없습니다."),
|
||||||
|
|
||||||
|
// AI 추천 관련 오류
|
||||||
|
RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."),
|
||||||
|
EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "R002", "외부 API 호출에 실패했습니다."),
|
||||||
|
|
||||||
|
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "F001", "파일을 찾을 수 없습니다."),
|
||||||
|
FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "F002", "파일 업로드에 실패했습니다."),
|
||||||
|
FILE_SIZE_EXCEEDED(HttpStatus.NOT_FOUND, "F003", "파일 크기가 제한을 초과했습니다."),
|
||||||
|
INVALID_FILE_EXTENSION(HttpStatus.NOT_FOUND, "F004", "지원하지 않는 파일 확장자입니다."),
|
||||||
|
INVALID_FILE_TYPE(HttpStatus.NOT_FOUND, "F005", "지원하지 않는 파일 형식입니다."),
|
||||||
|
INVALID_FILE_NAME(HttpStatus.NOT_FOUND, "F006", "잘못된 파일명입니다."),
|
||||||
|
INVALID_FILE_URL(HttpStatus.NOT_FOUND, "F007", "잘못된 파일 URL입니다."),
|
||||||
|
STORAGE_CONTAINER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "F008", "스토리지 컨테이너 오류가 발생했습니다."),
|
||||||
|
|
||||||
|
// 공통 오류
|
||||||
|
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류가 발생했습니다."),
|
||||||
|
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."),
|
||||||
|
INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "G003", "잘못된 타입의 값입니다."),
|
||||||
|
MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "G004", "필수 요청 파라미터가 누락되었습니다."),
|
||||||
|
ACCESS_DENIED(HttpStatus.FORBIDDEN, "G005", "접근이 거부되었습니다."),
|
||||||
|
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G006", "허용되지 않은 HTTP 메서드입니다.");
|
||||||
|
|
||||||
|
private final HttpStatus httpStatus;
|
||||||
|
private final String code;
|
||||||
|
private final String message;
|
||||||
|
}
|
||||||
@ -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<ApiResponse<Void>> 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<ApiResponse<Map<String, String>>> handleValidationException(
|
||||||
|
MethodArgumentNotValidException ex) {
|
||||||
|
log.warn("Validation exception occurred: {}", ex.getMessage());
|
||||||
|
|
||||||
|
Map<String, String> 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.<Map<String, String>>builder()
|
||||||
|
.status(400)
|
||||||
|
.message("입력값 검증에 실패했습니다.")
|
||||||
|
.data(errors)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일반적인 예외 처리
|
||||||
|
*
|
||||||
|
* @param ex 예외
|
||||||
|
* @return 오류 응답
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
|
||||||
|
log.error("Unexpected exception occurred", ex);
|
||||||
|
|
||||||
|
return ResponseEntity.internalServerError()
|
||||||
|
.body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
package com.won.smarketing.common.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 인증 필터
|
||||||
|
* HTTP 요청에서 JWT 토큰을 추출하고 인증 처리
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||||
|
private static final String BEARER_PREFIX = "Bearer ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 토큰 기반 인증 필터링
|
||||||
|
*
|
||||||
|
* @param request HTTP 요청
|
||||||
|
* @param response HTTP 응답
|
||||||
|
* @param filterChain 필터 체인
|
||||||
|
* @throws ServletException 서블릿 예외
|
||||||
|
* @throws IOException IO 예외
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP 요청에서 JWT 토큰 추출
|
||||||
|
*
|
||||||
|
* @param request HTTP 요청
|
||||||
|
* @return JWT 토큰 (Bearer 접두사 제거된)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
217
smarketing-java/deployment/Jenkinsfile
vendored
Normal file
217
smarketing-java/deployment/Jenkinsfile
vendored
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
def PIPELINE_ID = "${env.BUILD_NUMBER}"
|
||||||
|
|
||||||
|
def getImageTag() {
|
||||||
|
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
|
||||||
|
def currentDate = new Date()
|
||||||
|
return dateFormat.format(currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
podTemplate(
|
||||||
|
label: "${PIPELINE_ID}",
|
||||||
|
serviceAccount: 'jenkins',
|
||||||
|
containers: [
|
||||||
|
containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'),
|
||||||
|
containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, privileged: true),
|
||||||
|
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
|
||||||
|
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
|
||||||
|
],
|
||||||
|
volumes: [
|
||||||
|
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
|
||||||
|
emptyDirVolume(mountPath: '/root/.azure', memory: false),
|
||||||
|
emptyDirVolume(mountPath: '/var/run', memory: false)
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
node(PIPELINE_ID) {
|
||||||
|
def props
|
||||||
|
def imageTag = getImageTag()
|
||||||
|
def manifest = "deploy.yaml"
|
||||||
|
def namespace
|
||||||
|
def services = ['member', 'store', 'marketing-content', 'ai-recommend']
|
||||||
|
|
||||||
|
stage("Get Source") {
|
||||||
|
checkout scm
|
||||||
|
|
||||||
|
// smarketing-java 하위에 있는 설정 파일 읽기
|
||||||
|
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
|
||||||
|
namespace = "${props.namespace}"
|
||||||
|
|
||||||
|
echo "=== Build Information ==="
|
||||||
|
echo "Services: ${services}"
|
||||||
|
echo "Namespace: ${namespace}"
|
||||||
|
echo "Image Tag: ${imageTag}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stage("Setup AKS") {
|
||||||
|
container('azure-cli') {
|
||||||
|
withCredentials([azureServicePrincipal('azure-credentials')]) {
|
||||||
|
sh """
|
||||||
|
echo "=== Azure 로그인 ==="
|
||||||
|
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
|
||||||
|
az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66
|
||||||
|
|
||||||
|
echo "=== AKS 인증정보 가져오기 ==="
|
||||||
|
az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing
|
||||||
|
|
||||||
|
echo "=== 네임스페이스 생성 ==="
|
||||||
|
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
echo "=== Image Pull Secret 생성 ==="
|
||||||
|
kubectl create secret docker-registry acr-secret \\
|
||||||
|
--docker-server=${props.registry} \\
|
||||||
|
--docker-username=acrdigitalgarage02 \\
|
||||||
|
--docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\
|
||||||
|
--namespace=${namespace} \\
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
echo "=== 클러스터 상태 확인 ==="
|
||||||
|
kubectl get nodes
|
||||||
|
kubectl get ns ${namespace}
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build Applications') {
|
||||||
|
container('gradle') {
|
||||||
|
sh """
|
||||||
|
echo "=== smarketing-java 디렉토리로 이동 ==="
|
||||||
|
cd smarketing-java
|
||||||
|
|
||||||
|
echo "=== gradlew 권한 설정 ==="
|
||||||
|
chmod +x gradlew
|
||||||
|
|
||||||
|
echo "=== 전체 서비스 빌드 ==="
|
||||||
|
./gradlew :member:clean :member:build -x test
|
||||||
|
./gradlew :store:clean :store:build -x test
|
||||||
|
./gradlew :marketing-content:clean :marketing-content:build -x test
|
||||||
|
./gradlew :ai-recommend:clean :ai-recommend:build -x test
|
||||||
|
|
||||||
|
echo "=== 빌드 결과 확인 ==="
|
||||||
|
find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar'
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build & Push Images') {
|
||||||
|
container('docker') {
|
||||||
|
sh """
|
||||||
|
echo "=== Docker 데몬 시작 대기 ==="
|
||||||
|
timeout 30 sh -c 'until docker info; do sleep 1; done'
|
||||||
|
"""
|
||||||
|
|
||||||
|
// 🔧 ACR Credential을 Jenkins에서 직접 사용
|
||||||
|
withCredentials([usernamePassword(
|
||||||
|
credentialsId: 'acr-credentials',
|
||||||
|
usernameVariable: 'ACR_USERNAME',
|
||||||
|
passwordVariable: 'ACR_PASSWORD'
|
||||||
|
)]) {
|
||||||
|
sh """
|
||||||
|
echo "=== Docker로 ACR 로그인 ==="
|
||||||
|
echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin
|
||||||
|
"""
|
||||||
|
|
||||||
|
services.each { service ->
|
||||||
|
script {
|
||||||
|
def buildDir = "smarketing-java/${service}"
|
||||||
|
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
|
||||||
|
|
||||||
|
echo "Building image for ${service}: ${fullImageName}"
|
||||||
|
|
||||||
|
// 실제 JAR 파일명 동적 탐지
|
||||||
|
def actualJarFile = sh(
|
||||||
|
script: """
|
||||||
|
cd ${buildDir}/build/libs
|
||||||
|
ls *.jar | grep -v 'plain.jar' | head -1
|
||||||
|
""",
|
||||||
|
returnStdout: true
|
||||||
|
).trim()
|
||||||
|
|
||||||
|
if (!actualJarFile) {
|
||||||
|
error "${service} JAR 파일을 찾을 수 없습니다"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "발견된 JAR 파일: ${actualJarFile}"
|
||||||
|
|
||||||
|
sh """
|
||||||
|
echo "=== ${service} 이미지 빌드 ==="
|
||||||
|
docker build \\
|
||||||
|
--build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\
|
||||||
|
--build-arg ARTIFACTORY_FILE="${actualJarFile}" \\
|
||||||
|
-f smarketing-java/deployment/container/Dockerfile \\
|
||||||
|
-t ${fullImageName} .
|
||||||
|
|
||||||
|
echo "=== ${service} 이미지 푸시 ==="
|
||||||
|
docker push ${fullImageName}
|
||||||
|
|
||||||
|
echo "Successfully built and pushed: ${fullImageName}"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Generate & Apply Manifest') {
|
||||||
|
container('envsubst') {
|
||||||
|
sh """
|
||||||
|
echo "=== 환경변수 설정 ==="
|
||||||
|
export namespace=${namespace}
|
||||||
|
export allowed_origins=${props.allowed_origins}
|
||||||
|
export jwt_secret_key=${props.jwt_secret_key}
|
||||||
|
export postgres_user=${props.postgres_user}
|
||||||
|
export postgres_password=${props.postgres_password}
|
||||||
|
export replicas=${props.replicas}
|
||||||
|
# 리소스 요구사항 조정 (작게)
|
||||||
|
export resources_requests_cpu=100m
|
||||||
|
export resources_requests_memory=128Mi
|
||||||
|
export resources_limits_cpu=500m
|
||||||
|
export resources_limits_memory=512Mi
|
||||||
|
|
||||||
|
# 이미지 경로 환경변수 설정
|
||||||
|
export member_image_path=${props.registry}/${props.image_org}/member:${imageTag}
|
||||||
|
export store_image_path=${props.registry}/${props.image_org}/store:${imageTag}
|
||||||
|
export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag}
|
||||||
|
export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag}
|
||||||
|
|
||||||
|
echo "=== Manifest 생성 ==="
|
||||||
|
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
|
||||||
|
|
||||||
|
echo "=== Generated Manifest File ==="
|
||||||
|
cat smarketing-java/deployment/${manifest}
|
||||||
|
echo "==============================="
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
container('azure-cli') {
|
||||||
|
sh """
|
||||||
|
echo "=== PostgreSQL 서비스 확인 ==="
|
||||||
|
kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요."
|
||||||
|
|
||||||
|
echo "=== Manifest 적용 ==="
|
||||||
|
kubectl apply -f smarketing-java/deployment/${manifest}
|
||||||
|
|
||||||
|
echo "=== 배포 상태 확인 (60초 대기) ==="
|
||||||
|
kubectl -n ${namespace} get deployments
|
||||||
|
kubectl -n ${namespace} get pods
|
||||||
|
|
||||||
|
echo "=== 각 서비스 배포 대기 (60초 timeout) ==="
|
||||||
|
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃"
|
||||||
|
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃"
|
||||||
|
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃"
|
||||||
|
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃"
|
||||||
|
|
||||||
|
echo "=== 최종 상태 ==="
|
||||||
|
kubectl -n ${namespace} get all
|
||||||
|
|
||||||
|
echo "=== 실패한 Pod 상세 정보 ==="
|
||||||
|
for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do
|
||||||
|
if [ ! -z "\$pod" ]; then
|
||||||
|
echo "=== 실패한 Pod: \$pod ==="
|
||||||
|
kubectl -n ${namespace} describe \$pod | tail -20
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
smarketing-java/deployment/container/Dockerfile
Normal file
44
smarketing-java/deployment/container/Dockerfile
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM eclipse-temurin:17-jre AS builder
|
||||||
|
ARG BUILD_LIB_DIR
|
||||||
|
ARG ARTIFACTORY_FILE
|
||||||
|
WORKDIR /app
|
||||||
|
COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar
|
||||||
|
|
||||||
|
# Run stage
|
||||||
|
FROM eclipse-temurin:17-jre
|
||||||
|
|
||||||
|
# Install necessary packages
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
netcat-traditional \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV USERNAME k8s
|
||||||
|
ENV ARTIFACTORY_HOME /home/${USERNAME}
|
||||||
|
ENV JAVA_OPTS=""
|
||||||
|
|
||||||
|
# Add a non-root user
|
||||||
|
RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \
|
||||||
|
mkdir -p ${ARTIFACTORY_HOME} && \
|
||||||
|
chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME}
|
||||||
|
|
||||||
|
WORKDIR ${ARTIFACTORY_HOME}
|
||||||
|
|
||||||
|
# Copy JAR from builder stage
|
||||||
|
COPY --from=builder /app/app.jar app.jar
|
||||||
|
RUN chown ${USERNAME}:${USERNAME} app.jar
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER ${USERNAME}
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/actuator/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
ENTRYPOINT ["sh", "-c"]
|
||||||
|
CMD ["java ${JAVA_OPTS} -jar app.jar"]
|
||||||
475
smarketing-java/deployment/deploy.yaml.template
Normal file
475
smarketing-java/deployment/deploy.yaml.template
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
# ConfigMap
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: common-config
|
||||||
|
namespace: ${namespace}
|
||||||
|
data:
|
||||||
|
ALLOWED_ORIGINS: ${allowed_origins}
|
||||||
|
JPA_DDL_AUTO: update
|
||||||
|
JPA_SHOW_SQL: 'true'
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: member-config
|
||||||
|
namespace: ${namespace}
|
||||||
|
data:
|
||||||
|
POSTGRES_DB: member
|
||||||
|
POSTGRES_HOST: member-postgresql
|
||||||
|
POSTGRES_PORT: '5432'
|
||||||
|
SERVER_PORT: '8081'
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: store-config
|
||||||
|
namespace: ${namespace}
|
||||||
|
data:
|
||||||
|
POSTGRES_DB: store
|
||||||
|
POSTGRES_HOST: store-postgresql
|
||||||
|
POSTGRES_PORT: '5432'
|
||||||
|
SERVER_PORT: '8082'
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: marketing-content-config
|
||||||
|
namespace: ${namespace}
|
||||||
|
data:
|
||||||
|
POSTGRES_DB: marketing_content
|
||||||
|
POSTGRES_HOST: marketing-content-postgresql
|
||||||
|
POSTGRES_PORT: '5432'
|
||||||
|
SERVER_PORT: '8083'
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ai-recommend-config
|
||||||
|
namespace: ${namespace}
|
||||||
|
data:
|
||||||
|
POSTGRES_DB: ai_recommend
|
||||||
|
POSTGRES_HOST: ai-recommend-postgresql
|
||||||
|
POSTGRES_PORT: '5432'
|
||||||
|
SERVER_PORT: '8084'
|
||||||
|
|
||||||
|
---
|
||||||
|
# Secrets
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: common-secret
|
||||||
|
namespace: ${namespace}
|
||||||
|
stringData:
|
||||||
|
JWT_SECRET_KEY: ${jwt_secret_key}
|
||||||
|
type: Opaque
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: member-secret
|
||||||
|
namespace: ${namespace}
|
||||||
|
stringData:
|
||||||
|
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
|
||||||
|
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
type: Opaque
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: store-secret
|
||||||
|
namespace: ${namespace}
|
||||||
|
stringData:
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
type: Opaque
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: marketing-content-secret
|
||||||
|
namespace: ${namespace}
|
||||||
|
stringData:
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
type: Opaque
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: ai-recommend-secret
|
||||||
|
namespace: ${namespace}
|
||||||
|
stringData:
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
type: Opaque
|
||||||
|
|
||||||
|
---
|
||||||
|
# Deployments
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: member
|
||||||
|
namespace: ${namespace}
|
||||||
|
labels:
|
||||||
|
app: member
|
||||||
|
spec:
|
||||||
|
replicas: ${replicas}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: member
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: member
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: member
|
||||||
|
image: ${member_image_path}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8081
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: ${resources_requests_cpu}
|
||||||
|
memory: ${resources_requests_memory}
|
||||||
|
limits:
|
||||||
|
cpu: ${resources_limits_cpu}
|
||||||
|
memory: ${resources_limits_memory}
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: common-config
|
||||||
|
- configMapRef:
|
||||||
|
name: member-config
|
||||||
|
- secretRef:
|
||||||
|
name: common-secret
|
||||||
|
- secretRef:
|
||||||
|
name: member-secret
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "nc -z member-postgresql 5432"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8081
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8081
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 5
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: store
|
||||||
|
namespace: ${namespace}
|
||||||
|
labels:
|
||||||
|
app: store
|
||||||
|
spec:
|
||||||
|
replicas: ${replicas}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: store
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: store
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: store
|
||||||
|
image: ${store_image_path}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8082
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: ${resources_requests_cpu}
|
||||||
|
memory: ${resources_requests_memory}
|
||||||
|
limits:
|
||||||
|
cpu: ${resources_limits_cpu}
|
||||||
|
memory: ${resources_limits_memory}
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: common-config
|
||||||
|
- configMapRef:
|
||||||
|
name: store-config
|
||||||
|
- secretRef:
|
||||||
|
name: common-secret
|
||||||
|
- secretRef:
|
||||||
|
name: store-secret
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "nc -z store-postgresql 5432"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8082
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8082
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 5
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: marketing-content
|
||||||
|
namespace: ${namespace}
|
||||||
|
labels:
|
||||||
|
app: marketing-content
|
||||||
|
spec:
|
||||||
|
replicas: ${replicas}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: marketing-content
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: marketing-content
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: marketing-content
|
||||||
|
image: ${marketing_content_image_path}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8083
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: ${resources_requests_cpu}
|
||||||
|
memory: ${resources_requests_memory}
|
||||||
|
limits:
|
||||||
|
cpu: ${resources_limits_cpu}
|
||||||
|
memory: ${resources_limits_memory}
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: common-config
|
||||||
|
- configMapRef:
|
||||||
|
name: marketing-content-config
|
||||||
|
- secretRef:
|
||||||
|
name: common-secret
|
||||||
|
- secretRef:
|
||||||
|
name: marketing-content-secret
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "nc -z marketing-content-postgresql 5432"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8083
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8083
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 5
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ai-recommend
|
||||||
|
namespace: ${namespace}
|
||||||
|
labels:
|
||||||
|
app: ai-recommend
|
||||||
|
spec:
|
||||||
|
replicas: ${replicas}
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ai-recommend
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ai-recommend
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: acr-secret
|
||||||
|
containers:
|
||||||
|
- name: ai-recommend
|
||||||
|
image: ${ai_recommend_image_path}
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8084
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: ${resources_requests_cpu}
|
||||||
|
memory: ${resources_requests_memory}
|
||||||
|
limits:
|
||||||
|
cpu: ${resources_limits_cpu}
|
||||||
|
memory: ${resources_limits_memory}
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: common-config
|
||||||
|
- configMapRef:
|
||||||
|
name: ai-recommend-config
|
||||||
|
- secretRef:
|
||||||
|
name: common-secret
|
||||||
|
- secretRef:
|
||||||
|
name: ai-recommend-secret
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- "nc -z ai-recommend-postgresql 5432"
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8084
|
||||||
|
initialDelaySeconds: 60
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /actuator/health
|
||||||
|
port: 8084
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 5
|
||||||
|
|
||||||
|
---
|
||||||
|
# Services
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: member
|
||||||
|
namespace: ${namespace}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: member
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8081
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: store
|
||||||
|
namespace: ${namespace}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: store
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8082
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: marketing-content
|
||||||
|
namespace: ${namespace}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: marketing-content
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8083
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ai-recommend
|
||||||
|
namespace: ${namespace}
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ai-recommend
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8084
|
||||||
|
type: ClusterIP
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: smarketing-backend
|
||||||
|
namespace: ${namespace}
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: nginx
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- http:
|
||||||
|
paths:
|
||||||
|
- path: /api/auth
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: member
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- path: /api/store
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: store
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- path: /api/content
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: marketing-content
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
- path: /api/recommend
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: ai-recommend
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
23
smarketing-java/deployment/deploy_env_vars
Normal file
23
smarketing-java/deployment/deploy_env_vars
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Team Settings
|
||||||
|
teamid=kros235
|
||||||
|
root_project=smarketing-backend
|
||||||
|
namespace=smarketing
|
||||||
|
|
||||||
|
# Container Registry Settings
|
||||||
|
registry=acrdigitalgarage02.azurecr.io
|
||||||
|
image_org=smarketing
|
||||||
|
|
||||||
|
# Application Settings
|
||||||
|
replicas=1
|
||||||
|
allowed_origins=http://20.249.171.38
|
||||||
|
|
||||||
|
# Security Settings
|
||||||
|
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
|
||||||
|
postgres_user=admin
|
||||||
|
postgres_password=Hi5Jessica!
|
||||||
|
|
||||||
|
# Resource Settings (리소스 요구사항 줄임)
|
||||||
|
resources_requests_cpu=100m
|
||||||
|
resources_requests_memory=128Mi
|
||||||
|
resources_limits_cpu=500m
|
||||||
|
resources_limits_memory=512Mi
|
||||||
BIN
smarketing-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
smarketing-java/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
smarketing-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
smarketing-java/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
251
smarketing-java/gradlew
vendored
Normal file
251
smarketing-java/gradlew
vendored
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
94
smarketing-java/gradlew.bat
vendored
Normal file
94
smarketing-java/gradlew.bat
vendored
Normal file
@ -0,0 +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
|
||||||
4
smarketing-java/marketing-content/build.gradle
Normal file
4
smarketing-java/marketing-content/build.gradle
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
dependencies {
|
||||||
|
implementation project(':common')
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
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"
|
||||||
|
})
|
||||||
|
@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) {
|
||||||
|
SpringApplication.run(MarketingContentServiceApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,191 @@
|
|||||||
|
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.*;
|
||||||
|
import com.won.smarketing.content.domain.repository.ContentRepository;
|
||||||
|
import com.won.smarketing.content.presentation.dto.*;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 조회 서비스 구현체
|
||||||
|
* 콘텐츠 수정, 조회, 삭제 기능 구현
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class ContentQueryService implements ContentQueryUseCase {
|
||||||
|
|
||||||
|
private final ContentRepository contentRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 수정
|
||||||
|
*
|
||||||
|
* @param contentId 수정할 콘텐츠 ID
|
||||||
|
* @param request 콘텐츠 수정 요청
|
||||||
|
* @return 수정된 콘텐츠 정보
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request) {
|
||||||
|
Content content = contentRepository.findById(ContentId.of(contentId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
|
||||||
|
|
||||||
|
// 제목과 기간 업데이트
|
||||||
|
content.updateTitle(request.getTitle());
|
||||||
|
content.updatePeriod(request.getPromotionStartDate(), request.getPromotionEndDate());
|
||||||
|
|
||||||
|
Content updatedContent = contentRepository.save(content);
|
||||||
|
|
||||||
|
return ContentUpdateResponse.builder()
|
||||||
|
.contentId(updatedContent.getId())
|
||||||
|
//.contentType(updatedContent.getContentType().name())
|
||||||
|
//.platform(updatedContent.getPlatform().name())
|
||||||
|
.title(updatedContent.getTitle())
|
||||||
|
.content(updatedContent.getContent())
|
||||||
|
//.hashtags(updatedContent.getHashtags())
|
||||||
|
//.images(updatedContent.getImages())
|
||||||
|
.status(updatedContent.getStatus().name())
|
||||||
|
.updatedAt(updatedContent.getUpdatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 목록 조회
|
||||||
|
*
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @param platform 플랫폼
|
||||||
|
* @param period 기간
|
||||||
|
* @param sortBy 정렬 기준
|
||||||
|
* @return 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ContentResponse> getContents(String contentType, String platform, String period, String sortBy) {
|
||||||
|
ContentType type = contentType != null ? ContentType.fromString(contentType) : null;
|
||||||
|
Platform platformEnum = platform != null ? Platform.fromString(platform) : null;
|
||||||
|
|
||||||
|
List<Content> contents = contentRepository.findByFilters(type, platformEnum, period, sortBy);
|
||||||
|
|
||||||
|
return contents.stream()
|
||||||
|
.map(this::toContentResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 중인 콘텐츠 목록 조회
|
||||||
|
*
|
||||||
|
* @param period 기간
|
||||||
|
* @return 진행 중인 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<OngoingContentResponse> getOngoingContents(String period) {
|
||||||
|
List<Content> contents = contentRepository.findOngoingContents(period);
|
||||||
|
|
||||||
|
return contents.stream()
|
||||||
|
.map(this::toOngoingContentResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 상세 조회
|
||||||
|
*
|
||||||
|
* @param contentId 콘텐츠 ID
|
||||||
|
* @return 콘텐츠 상세 정보
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ContentDetailResponse getContentDetail(Long contentId) {
|
||||||
|
Content content = contentRepository.findById(ContentId.of(contentId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
|
||||||
|
|
||||||
|
return ContentDetailResponse.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())
|
||||||
|
.creationConditions(toCreationConditionsDto(content.getCreationConditions()))
|
||||||
|
.createdAt(content.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 삭제
|
||||||
|
*
|
||||||
|
* @param contentId 삭제할 콘텐츠 ID
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteContent(Long contentId) {
|
||||||
|
Content content = contentRepository.findById(ContentId.of(contentId))
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND));
|
||||||
|
|
||||||
|
contentRepository.deleteById(ContentId.of(contentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content 엔티티를 ContentResponse DTO로 변환
|
||||||
|
*
|
||||||
|
* @param content Content 엔티티
|
||||||
|
* @return ContentResponse DTO
|
||||||
|
*/
|
||||||
|
private ContentResponse toContentResponse(Content content) {
|
||||||
|
return 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())
|
||||||
|
.createdAt(content.getCreatedAt())
|
||||||
|
.viewCount(0) // TODO: 실제 조회 수 구현 필요
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content 엔티티를 OngoingContentResponse DTO로 변환
|
||||||
|
*
|
||||||
|
* @param content Content 엔티티
|
||||||
|
* @return OngoingContentResponse DTO
|
||||||
|
*/
|
||||||
|
private OngoingContentResponse toOngoingContentResponse(Content content) {
|
||||||
|
return OngoingContentResponse.builder()
|
||||||
|
.contentId(content.getId())
|
||||||
|
.contentType(content.getContentType().name())
|
||||||
|
.platform(content.getPlatform().name())
|
||||||
|
.title(content.getTitle())
|
||||||
|
.status(content.getStatus().name())
|
||||||
|
.promotionStartDate(content.getPromotionStartDate())
|
||||||
|
//.viewCount(0) // TODO: 실제 조회 수 구현 필요
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreationConditions를 DTO로 변환
|
||||||
|
*
|
||||||
|
* @param conditions CreationConditions 도메인 객체
|
||||||
|
* @return CreationConditionsDto
|
||||||
|
*/
|
||||||
|
private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) {
|
||||||
|
if (conditions == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentDetailResponse.CreationConditionsDto.builder()
|
||||||
|
.toneAndManner(conditions.getToneAndManner())
|
||||||
|
.emotionIntensity(conditions.getEmotionIntensity())
|
||||||
|
.eventName(conditions.getEventName())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
package com.won.smarketing.content.application.service;
|
||||||
|
|
||||||
|
import com.won.smarketing.content.application.usecase.PosterContentUseCase;
|
||||||
|
import com.won.smarketing.content.domain.model.Content;
|
||||||
|
import com.won.smarketing.content.domain.model.ContentStatus;
|
||||||
|
import com.won.smarketing.content.domain.model.ContentType;
|
||||||
|
import com.won.smarketing.content.domain.model.CreationConditions;
|
||||||
|
import com.won.smarketing.content.domain.model.Platform;
|
||||||
|
import com.won.smarketing.content.domain.repository.ContentRepository;
|
||||||
|
import com.won.smarketing.content.domain.service.AiPosterGenerator;
|
||||||
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
|
||||||
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
|
||||||
|
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 콘텐츠 서비스 구현체
|
||||||
|
* 홍보 포스터 생성 및 저장 기능 구현
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class PosterContentService implements PosterContentUseCase {
|
||||||
|
|
||||||
|
private final ContentRepository contentRepository;
|
||||||
|
private final AiPosterGenerator aiPosterGenerator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 콘텐츠 생성
|
||||||
|
*
|
||||||
|
* @param request 포스터 콘텐츠 생성 요청
|
||||||
|
* @return 생성된 포스터 콘텐츠 정보
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) {
|
||||||
|
// AI를 사용하여 포스터 생성
|
||||||
|
String generatedPoster = aiPosterGenerator.generatePoster(request);
|
||||||
|
|
||||||
|
// 다양한 사이즈의 포스터 생성
|
||||||
|
Map<String, String> posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster);
|
||||||
|
|
||||||
|
// 생성 조건 정보 구성
|
||||||
|
CreationConditions conditions = CreationConditions.builder()
|
||||||
|
.category(request.getCategory())
|
||||||
|
.requirement(request.getRequirement())
|
||||||
|
.toneAndManner(request.getToneAndManner())
|
||||||
|
.emotionIntensity(request.getEmotionIntensity())
|
||||||
|
.eventName(request.getEventName())
|
||||||
|
.startDate(request.getStartDate())
|
||||||
|
.endDate(request.getEndDate())
|
||||||
|
.photoStyle(request.getPhotoStyle())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return PosterContentCreateResponse.builder()
|
||||||
|
.contentId(null) // 임시 생성이므로 ID 없음
|
||||||
|
.contentType(ContentType.POSTER.name())
|
||||||
|
.title(request.getTitle())
|
||||||
|
.posterImage(generatedPoster)
|
||||||
|
.posterSizes(posterSizes)
|
||||||
|
.status(ContentStatus.DRAFT.name())
|
||||||
|
//.createdAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 콘텐츠 저장
|
||||||
|
*
|
||||||
|
* @param request 포스터 콘텐츠 저장 요청
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void savePosterContent(PosterContentSaveRequest request) {
|
||||||
|
// 생성 조건 정보 구성
|
||||||
|
CreationConditions conditions = CreationConditions.builder()
|
||||||
|
.category(request.getCategory())
|
||||||
|
.requirement(request.getRequirement())
|
||||||
|
.toneAndManner(request.getToneAndManner())
|
||||||
|
.emotionIntensity(request.getEmotionIntensity())
|
||||||
|
.eventName(request.getEventName())
|
||||||
|
.startDate(request.getStartDate())
|
||||||
|
.endDate(request.getEndDate())
|
||||||
|
.photoStyle(request.getPhotoStyle())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 콘텐츠 엔티티 생성 및 저장
|
||||||
|
Content content = Content.builder()
|
||||||
|
.contentType(ContentType.POSTER)
|
||||||
|
.platform(Platform.GENERAL) // 포스터는 범용
|
||||||
|
.title(request.getTitle())
|
||||||
|
.content(null) // 포스터는 이미지가 주 콘텐츠
|
||||||
|
.hashtags(null)
|
||||||
|
.images(request.getImages())
|
||||||
|
.status(ContentStatus.PUBLISHED)
|
||||||
|
.creationConditions(conditions)
|
||||||
|
.storeId(request.getStoreId())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.updatedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
contentRepository.save(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
package com.won.smarketing.content.application.service;
|
||||||
|
|
||||||
|
import com.won.smarketing.content.application.usecase.SnsContentUseCase;
|
||||||
|
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.CreationConditions;
|
||||||
|
import com.won.smarketing.content.domain.model.Platform;
|
||||||
|
import com.won.smarketing.content.domain.repository.ContentRepository;
|
||||||
|
import com.won.smarketing.content.domain.service.AiContentGenerator;
|
||||||
|
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
|
||||||
|
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
|
||||||
|
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 서비스 구현체
|
||||||
|
* SNS 게시물 생성 및 저장 기능 구현
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class SnsContentService implements SnsContentUseCase {
|
||||||
|
|
||||||
|
private final ContentRepository contentRepository;
|
||||||
|
private final AiContentGenerator aiContentGenerator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 생성
|
||||||
|
*
|
||||||
|
* @param request SNS 콘텐츠 생성 요청
|
||||||
|
* @return 생성된 SNS 콘텐츠 정보
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) {
|
||||||
|
// AI를 사용하여 SNS 콘텐츠 생성
|
||||||
|
String generatedContent = aiContentGenerator.generateSnsContent(request);
|
||||||
|
|
||||||
|
// 플랫폼에 맞는 해시태그 생성
|
||||||
|
Platform platform = Platform.fromString(request.getPlatform());
|
||||||
|
List<String> hashtags = aiContentGenerator.generateHashtags(generatedContent, platform);
|
||||||
|
|
||||||
|
// 생성 조건 정보 구성
|
||||||
|
CreationConditions conditions = CreationConditions.builder()
|
||||||
|
.category(request.getCategory())
|
||||||
|
.requirement(request.getRequirement())
|
||||||
|
.toneAndManner(request.getToneAndManner())
|
||||||
|
.emotionIntensity(request.getEmotionIntensity())
|
||||||
|
.eventName(request.getEventName())
|
||||||
|
.startDate(request.getStartDate())
|
||||||
|
.endDate(request.getEndDate())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 임시 콘텐츠 생성 (저장하지 않음)
|
||||||
|
Content content = Content.builder()
|
||||||
|
// .contentType(ContentType.SNS_POST)
|
||||||
|
.platform(platform)
|
||||||
|
.title(request.getTitle())
|
||||||
|
.content(generatedContent)
|
||||||
|
.hashtags(hashtags)
|
||||||
|
.images(request.getImages())
|
||||||
|
.status(ContentStatus.DRAFT)
|
||||||
|
.creationConditions(conditions)
|
||||||
|
.storeId(request.getStoreId())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.updatedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return SnsContentCreateResponse.builder()
|
||||||
|
.contentId(null) // 임시 생성이므로 ID 없음
|
||||||
|
.contentType(content.getContentType().name())
|
||||||
|
.platform(content.getPlatform().name())
|
||||||
|
.title(content.getTitle())
|
||||||
|
.content(content.getContent())
|
||||||
|
.hashtags(content.getHashtags())
|
||||||
|
.fixedImages(content.getImages())
|
||||||
|
.status(content.getStatus().name())
|
||||||
|
.createdAt(content.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 저장
|
||||||
|
*
|
||||||
|
* @param request SNS 콘텐츠 저장 요청
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void saveSnsContent(SnsContentSaveRequest request) {
|
||||||
|
// 생성 조건 정보 구성
|
||||||
|
CreationConditions conditions = CreationConditions.builder()
|
||||||
|
.category(request.getCategory())
|
||||||
|
.requirement(request.getRequirement())
|
||||||
|
.toneAndManner(request.getToneAndManner())
|
||||||
|
.emotionIntensity(request.getEmotionIntensity())
|
||||||
|
.eventName(request.getEventName())
|
||||||
|
.startDate(request.getStartDate())
|
||||||
|
.endDate(request.getEndDate())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 콘텐츠 엔티티 생성 및 저장
|
||||||
|
Content content = Content.builder()
|
||||||
|
// .contentType(ContentType.SNS_POST)
|
||||||
|
.platform(Platform.fromString(request.getPlatform()))
|
||||||
|
.title(request.getTitle())
|
||||||
|
.content(request.getContent())
|
||||||
|
.hashtags(request.getHashtags())
|
||||||
|
.images(request.getImages())
|
||||||
|
.status(ContentStatus.PUBLISHED)
|
||||||
|
.creationConditions(conditions)
|
||||||
|
.storeId(request.getStoreId())
|
||||||
|
.createdAt(LocalDateTime.now())
|
||||||
|
.updatedAt(LocalDateTime.now())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
contentRepository.save(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.won.smarketing.content.application.usecase;
|
||||||
|
|
||||||
|
import com.won.smarketing.content.presentation.dto.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 조회 관련 Use Case 인터페이스
|
||||||
|
* 콘텐츠 수정, 조회, 삭제 기능 정의
|
||||||
|
*/
|
||||||
|
public interface ContentQueryUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 수정
|
||||||
|
*
|
||||||
|
* @param contentId 수정할 콘텐츠 ID
|
||||||
|
* @param request 콘텐츠 수정 요청
|
||||||
|
* @return 수정된 콘텐츠 정보
|
||||||
|
*/
|
||||||
|
ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 목록 조회
|
||||||
|
*
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @param platform 플랫폼
|
||||||
|
* @param period 기간
|
||||||
|
* @param sortBy 정렬 기준
|
||||||
|
* @return 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<ContentResponse> getContents(String contentType, String platform, String period, String sortBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 중인 콘텐츠 목록 조회
|
||||||
|
*
|
||||||
|
* @param period 기간
|
||||||
|
* @return 진행 중인 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<OngoingContentResponse> getOngoingContents(String period);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 상세 조회
|
||||||
|
*
|
||||||
|
* @param contentId 콘텐츠 ID
|
||||||
|
* @return 콘텐츠 상세 정보
|
||||||
|
*/
|
||||||
|
ContentDetailResponse getContentDetail(Long contentId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 삭제
|
||||||
|
*
|
||||||
|
* @param contentId 삭제할 콘텐츠 ID
|
||||||
|
*/
|
||||||
|
void deleteContent(Long contentId);
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
// 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;
|
||||||
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
|
||||||
|
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 콘텐츠 관련 UseCase 인터페이스
|
||||||
|
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
|
||||||
|
*/
|
||||||
|
public interface PosterContentUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 콘텐츠 생성
|
||||||
|
* @param request 포스터 콘텐츠 생성 요청
|
||||||
|
* @return 포스터 콘텐츠 생성 응답
|
||||||
|
*/
|
||||||
|
PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 콘텐츠 저장
|
||||||
|
* @param request 포스터 콘텐츠 저장 요청
|
||||||
|
*/
|
||||||
|
void savePosterContent(PosterContentSaveRequest request);
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
// 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;
|
||||||
|
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
|
||||||
|
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 관련 UseCase 인터페이스
|
||||||
|
* Clean Architecture의 Application Layer에서 비즈니스 로직 정의
|
||||||
|
*/
|
||||||
|
public interface SnsContentUseCase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 생성
|
||||||
|
* @param request SNS 콘텐츠 생성 요청
|
||||||
|
* @return SNS 콘텐츠 생성 응답
|
||||||
|
*/
|
||||||
|
SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 저장
|
||||||
|
* @param request SNS 콘텐츠 저장 요청
|
||||||
|
*/
|
||||||
|
void saveSnsContent(SnsContentSaveRequest request);
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String> hashtags = new ArrayList<>();
|
||||||
|
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> 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<String> strings, List<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 + '}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
// 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;
|
||||||
|
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 java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 리포지토리 인터페이스
|
||||||
|
* Clean Architecture의 Domain Layer에서 데이터 접근 정의
|
||||||
|
*/
|
||||||
|
public interface ContentRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 저장
|
||||||
|
* @param content 저장할 콘텐츠
|
||||||
|
* @return 저장된 콘텐츠
|
||||||
|
*/
|
||||||
|
Content save(Content content);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID로 콘텐츠 조회
|
||||||
|
* @param id 콘텐츠 ID
|
||||||
|
* @return 조회된 콘텐츠
|
||||||
|
*/
|
||||||
|
Optional<Content> findById(ContentId id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필터 조건으로 콘텐츠 목록 조회
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @param platform 플랫폼
|
||||||
|
* @param period 기간
|
||||||
|
* @param sortBy 정렬 기준
|
||||||
|
* @return 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<Content> findByFilters(ContentType contentType, Platform platform, String period, String sortBy);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 진행 중인 콘텐츠 목록 조회
|
||||||
|
* @param period 기간
|
||||||
|
* @return 진행 중인 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<Content> findOngoingContents(String period);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID로 콘텐츠 삭제
|
||||||
|
* @param id 삭제할 콘텐츠 ID
|
||||||
|
*/
|
||||||
|
void deleteById(ContentId id);
|
||||||
|
}
|
||||||
@ -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<ContentEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 매장별 콘텐츠 조회
|
||||||
|
*
|
||||||
|
* @param storeId 매장 ID
|
||||||
|
* @return 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<ContentEntity> findByStoreId(Long storeId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콘텐츠 타입별 조회
|
||||||
|
*
|
||||||
|
* @param contentType 콘텐츠 타입
|
||||||
|
* @return 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<ContentEntity> findByContentType(String contentType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫폼별 조회
|
||||||
|
*
|
||||||
|
* @param platform 플랫폼
|
||||||
|
* @return 콘텐츠 목록
|
||||||
|
*/
|
||||||
|
List<ContentEntity> findByPlatform(String platform);
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.won.smarketing.content.domain.service;
|
||||||
|
|
||||||
|
import com.won.smarketing.content.domain.model.Platform;
|
||||||
|
import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 콘텐츠 생성 도메인 서비스 인터페이스
|
||||||
|
* SNS 콘텐츠 생성 및 해시태그 생성 기능 정의
|
||||||
|
*/
|
||||||
|
public interface AiContentGenerator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNS 콘텐츠 생성
|
||||||
|
*
|
||||||
|
* @param request SNS 콘텐츠 생성 요청
|
||||||
|
* @return 생성된 콘텐츠
|
||||||
|
*/
|
||||||
|
String generateSnsContent(SnsContentCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫폼별 해시태그 생성
|
||||||
|
*
|
||||||
|
* @param content 콘텐츠 내용
|
||||||
|
* @param platform 플랫폼
|
||||||
|
* @return 해시태그 목록
|
||||||
|
*/
|
||||||
|
List<String> generateHashtags(String content, Platform platform);
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.won.smarketing.content.domain.service;
|
||||||
|
|
||||||
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 포스터 생성 도메인 서비스 인터페이스
|
||||||
|
* 홍보 포스터 생성 및 다양한 사이즈 생성 기능 정의
|
||||||
|
*/
|
||||||
|
public interface AiPosterGenerator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 포스터 생성
|
||||||
|
*
|
||||||
|
* @param request 포스터 생성 요청
|
||||||
|
* @return 생성된 포스터 이미지 URL
|
||||||
|
*/
|
||||||
|
String generatePoster(PosterContentCreateRequest request);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다양한 사이즈의 포스터 생성
|
||||||
|
*
|
||||||
|
* @param baseImage 기본 이미지
|
||||||
|
* @return 사이즈별 포스터 URL 맵
|
||||||
|
*/
|
||||||
|
Map<String, String> generatePosterSizes(String baseImage);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<String> generateHashtags(String content, Platform platform);
|
||||||
|
}
|
||||||
@ -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<String, String> generatePosterSizes(String originalImage);
|
||||||
|
}
|
||||||
@ -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<String> 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<String> generateDummyHashtags(Platform platform) {
|
||||||
|
if (platform == Platform.INSTAGRAM) {
|
||||||
|
return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램");
|
||||||
|
} else {
|
||||||
|
return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> generateFallbackHashtags() {
|
||||||
|
return Arrays.asList("#소상공인", "#마케팅", "#홍보");
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user