commit 44d7312a8502f6f49d6d7d978507acd61104ad1c Author: OhSeongRak Date: Tue Jun 17 10:05:16 2025 +0900 release diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..fc7acb6 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "customColor": "", + "associatedIndex": 4 +} + + + + + + + + + + + + + + + true + true + false + false + + + + + + + 1749618504890 + + + + \ No newline at end of file diff --git a/smarketing-ai/.gitignore b/smarketing-ai/.gitignore new file mode 100644 index 0000000..0ee64c1 --- /dev/null +++ b/smarketing-ai/.gitignore @@ -0,0 +1,23 @@ +# Python 가상환경 +venv/ +env/ +ENV/ +.venv/ +.env/ + +# Python 캐시 +__pycache__/ +*.py[cod] +*$py.class +*.so + +# 환경 변수 파일 +.env +.env.local +.env.*.local + +# IDE 설정 +.vscode/ +.idea/ +*.swp +*.swo \ No newline at end of file diff --git a/smarketing-ai/api/marketing_tip_api.py b/smarketing-ai/api/marketing_tip_api.py new file mode 100644 index 0000000..f331932 --- /dev/null +++ b/smarketing-ai/api/marketing_tip_api.py @@ -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 \ No newline at end of file diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py new file mode 100644 index 0000000..d3c91da --- /dev/null +++ b/smarketing-ai/app.py @@ -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) diff --git a/smarketing-ai/config/__init__.py b/smarketing-ai/config/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/config/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/config/config.py b/smarketing-ai/config/config.py new file mode 100644 index 0000000..dc6e8ac --- /dev/null +++ b/smarketing-ai/config/config.py @@ -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 diff --git a/smarketing-ai/deployment/Dockerfile b/smarketing-ai/deployment/Dockerfile new file mode 100644 index 0000000..223ed21 --- /dev/null +++ b/smarketing-ai/deployment/Dockerfile @@ -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"] \ No newline at end of file diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile new file mode 100644 index 0000000..1abbdb4 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile @@ -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 "==========================================" + """ + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/Jenkinsfile_ArgoCD b/smarketing-ai/deployment/Jenkinsfile_ArgoCD new file mode 100644 index 0000000..1f86a02 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile_ArgoCD @@ -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 "==========================================" + """ + } + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy.yaml.template b/smarketing-ai/deployment/deploy.yaml.template new file mode 100644 index 0000000..2f35b44 --- /dev/null +++ b/smarketing-ai/deployment/deploy.yaml.template @@ -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 \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy_env_vars b/smarketing-ai/deployment/deploy_env_vars new file mode 100644 index 0000000..6f33b33 --- /dev/null +++ b/smarketing-ai/deployment/deploy_env_vars @@ -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 \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/configmap.yaml b/smarketing-ai/deployment/manifest/configmap.yaml new file mode 100644 index 0000000..798804c --- /dev/null +++ b/smarketing-ai/deployment/manifest/configmap.yaml @@ -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" \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/deployment.yaml b/smarketing-ai/deployment/manifest/deployment.yaml new file mode 100644 index 0000000..cc53cb5 --- /dev/null +++ b/smarketing-ai/deployment/manifest/deployment.yaml @@ -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: {} \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/ingress.yaml b/smarketing-ai/deployment/manifest/ingress.yaml new file mode 100644 index 0000000..5b5c4f4 --- /dev/null +++ b/smarketing-ai/deployment/manifest/ingress.yaml @@ -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 \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml new file mode 100644 index 0000000..cf24d0b --- /dev/null +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: smarketing-secret + namespace: smarketing +type: Opaque + +data: + OPENAI_API_KEY: c2stcHJvai1BbjRRX3VTNnNzQkxLU014VXBYTDBPM0lteUJuUjRwNVFTUHZkRnNSeXpFWGE0M21ISnhBcUkzNGZQOEduV2ZxclBpQ29VZ2pmbFQzQmxia0ZKZklMUGVqUFFIem9ZYzU4Yzc4UFkzeUo0dkowTVlfNGMzNV82dFlQUlkzTDBIODAwWWVvMnpaTmx6V3hXNk1RMFRzSDg5T1lNWUEK \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/service.yaml b/smarketing-ai/deployment/manifest/service.yaml new file mode 100644 index 0000000..08dc1e8 --- /dev/null +++ b/smarketing-ai/deployment/manifest/service.yaml @@ -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 \ No newline at end of file diff --git a/smarketing-ai/models/__init__.py b/smarketing-ai/models/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/models/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/models/marketing_tip_models.py b/smarketing-ai/models/marketing_tip_models.py new file mode 100644 index 0000000..5c47e84 --- /dev/null +++ b/smarketing-ai/models/marketing_tip_models.py @@ -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" + } + } diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py new file mode 100644 index 0000000..3f6952d --- /dev/null +++ b/smarketing-ai/models/request_models.py @@ -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 diff --git a/smarketing-ai/requirements.txt b/smarketing-ai/requirements.txt new file mode 100644 index 0000000..24cd87b --- /dev/null +++ b/smarketing-ai/requirements.txt @@ -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 \ No newline at end of file diff --git a/smarketing-ai/services/__init__.py b/smarketing-ai/services/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/services/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/services/marketing_tip_service.py b/smarketing-ai/services/marketing_tip_service.py new file mode 100644 index 0000000..db5526a --- /dev/null +++ b/smarketing-ai/services/marketing_tip_service.py @@ -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. 차별화된 서비스 제공 + +📱 실행 방법: +- 네이버 플레이스, 구글 정보 최신화 +- 고객 불만 신속 해결로 신뢰 구축 +- 작은 이벤트라도 꾸준히 진행 + +💰 비용: 거의 무료 (시간 투자 위주) +📈 기대효과: 꾸준한 성장과 단골 확보 + +⚠️ 핵심은 지속성입니다!""" diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py new file mode 100644 index 0000000..c90119c --- /dev/null +++ b/smarketing-ai/services/poster_service.py @@ -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 diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py new file mode 100644 index 0000000..680a1e7 --- /dev/null +++ b/smarketing-ai/services/sns_content_service.py @@ -0,0 +1,2005 @@ +""" +SNS 콘텐츠 생성 서비스 (플랫폼 특화 개선) +""" +import os +from typing import Dict, Any, List, Tuple +from datetime import datetime +from utils.ai_client import AIClient +from utils.image_processor import ImageProcessor +from models.request_models import SnsContentGetRequest + + +class SnsContentService: + + def __init__(self): + """서비스 초기화""" + self.ai_client = AIClient() + self.image_processor = ImageProcessor() + + # 블로그 글 예시 + self.blog_example = [ + { + "raw_html": """
+
+
+
+
+

팔공

중국음식하면 짬뽕이 제일 먼저 저는 떠오릅니다. 어릴 적 부터 짜장은 그닥 좋아하지 않았기에 지금도 짜장 보다는 짬뽕 그리고 볶음밥을 더 사랑합니다.(탕수육도 그닥 좋아하지는 않습니다) 지난 주말 11시30분쯤 갔다가 기겁(?)을 하고 일산으로 갔었던 기억이 납니다. 이날은 평일 조금 늦은 시간이기에 웨이팅이 없겠지 하고 갔습니다. 다행히 웨이팅은 없는데 홀에 딱 한자리가 있어서 다행히 착석을 하고 주문을 합니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

중화요리 팔공

위치안내: 서울 관악구 남부순환로 1680

영업시간: 11시 20분 ~ 21시 30분( 15시 ~ 17시 브레이크타임, 일요일 휴무)

메뉴: 짜장면, 해물짬뽕, 고기짬뽕, 볶음밥, 탕수육등

+
+
+
+
+
+
+
+
+

3명이 주문한 메뉴는 짜장면, 옛날볶음밥, 팔공해물짬뽕 2개 총 4가지 주문을 합니다.

+
+
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ + +
+
+ +
+
+
+
+

오랜만에 오셨네요 하셔서 " 이젠 와인 못 마시겠네요 "했더니 웃으시더군요 ㅎ

https://blog.naver.com/melburne/222278591313

+
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

차림료

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

밑반찬들 ㅎ

요즘 짜사이 주는 곳 참 좋아합니다. 어디였더라? 짜사이가 엄청 맛있었던 곳이 얼마 전 있었는데 음.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

옛날볶음밥(12,000원)

불맛나고 고슬고슬 잘 볶아낸 볶음밥에 바로 볶아서 내어주는 짜장까지 정말이지 훌륭한 볶음밥입니다. 오랜만에 만나다보니 흥문을 ㅎ

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

고슬고슬 기름기 없이 볶아내서 내어주십니다. 3명이서 총 4개의 메뉴를 주문했습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

후라이가 아쉽네요. 튀긴 옛날 후라이가 좋은데 아습입니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이집 계란국도 헛투루 내어주지 않으십니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

짜장과 함께 먹는 볶음밥은 역시 굿입니다. 맛나네요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

짜장면(10.000원)

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

일반짜장면이라고 하기보다는 채소도 큼직한 간짜장이라고 보시는 게 맞을 거 같습니다,.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

면에 짜장이 잘 베이면서 진득한게 끝내주죠. 저는 한 젓가락 조금 얻어서 맛을 봤는데 역시나 좋네요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

팔공해물짬뽕(13,000원)

최근래 먹은 해물짬뽕 중에서 해산물이 제일 많이 들어 있다고 해야할까요? 큼직큼직하게 들어 있으면서 묵직한 듯 한게 눈으로만 봐도 '맛있겠구나' 라는 생각이 팍팍 들었습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

처음 나온 볶음밥은 셋이서 맛나게 먹고 각자의 음식을 탐닉하기 시작합니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

탱글탱글한 해물들이 어짜피 냉동이겠지만 그래도 싱싱(?)한 듯 맛있습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

면발도 좋고 캬~...

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비싼(?)선동오징어도 푸짐하게 들어있네요. 대왕이, 솔방울 이런 거 없습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

맛있는 짬뽕은 해산물부터 국물까지 다 맛있습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

줄을 서는 게 무서워서 국물 한방울 안남기고 클리어 했습니다. (국물이 구수하면서 적당히 묵직하고 정말 맛있습니다.)

+
+
+
+ +
+
+
+
+

최종평가: 올해 먹은 짬뽕 중 최고라고 감히 말을 할 수 있을 거 같습니다. 예전보다 더 맛있어 졌으니 사람이 더 많아졌겠죠. 참고로 옛날고기짬뽕은 1시30분전에 솔드아웃된다고 합니다.

+
+
+
+
+
+
+
+
+

+
+
+
+ +
""", + "title": "팔공", + "summary": "중화요리 맛집 홍보" + }, + { + "raw_html": + """
+
+
+
+
+

[남천동 맛집] 안목 - 훌륭한 돼지국밥 한 그릇

미쉐린에 선택한 식당에 특별히 호감이 가는 것은 아니다.

하지만 궁금하기는 하다.

어떤 점에서 좋게 보고 선정을 한 것인지 궁금했다.

내가 가본 식당이라면 판단하면 되겠지만 가보지 않은 식당이라면 그 궁금증은 더 크다.

특히 가장 대중적인 음식이라면 더 클 것이다.

부산의 미쉐린 빕구르망에 2년 연속 선정한 돼지국밥집이 있다.

오가며 보기는 했지만 아직 가보진 못했다.

일부러 찾아가 보았다.

남천동의 "안목"이다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

정문 사진을 찍지 못해서 구글에서 하나 가져왔다. 밖에서 봐도 돼지국밥집 같아 보이지 않는다. 깔끔하고 모던하다.

남천동 등기소 바로 옆 건물이다. 주차장은 별도로 없으니 뒷골목의 주차장을 이용하여야 한다.

그런데 상호의 느낌은 일본풍같이 느껴진다. 혹시 그 뜻을 아시는 분들은 좀 알려주시면 고맙겠다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

좌석은 테이블은 없고 카운터석으로만 되어 있다. 최근 이름난 돼지국밥집들은 다 이런 식으로 만드는 것 같다.

전에 지나다 줄을 서는 것을 보았는데 이날 비가 와서 그랬는지 한가하다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

메뉴가 심플하다. 그냥 돼지국밥에 머릿고기 국밥 정도이다. 수육과 냉제육이 있는데 다음에 가게 되면 먹어보고 싶다.

가격은 비싸지 않은 것은 아닌데 더 비싸지 않아서 다행스럽다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

첨가할 수 있는 여러 가지

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

이런 것들이 있는데 마늘만 넣어 먹었다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

내가 주문한 머릿고기 국밥이다. 1인분씩 담겨 나온다.

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

머리 위의 선반에 쟁반이 올려져 있으면 그것을 내가 받아서 먹어야 한다. 반찬은 특별한 것은 없는데 이날 풋고추가 맛있었다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

굉장히 뽀얀 국물의 국밥이다. 머릿고기가 올려져 있다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이것은 아내가 먹은 그냥 돼지국밥이다. 고기만 다른 국밥이다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

국밥에는 간이 되어 있어서 더 넣지 않아도 충분히 먹을 수 있었다. 그러니 다진 양념이나 새우젓은 맛을 보고 첨가하시길....

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

일본 라멘에 넣는 마늘을 짜서 넣는다. 하나 정도면 충분하겠다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

맛있게 잘 먹었다.

맛있다. 쵸 근래 너무 저가의 돼지국밥만 먹고 다녀서인지 안목의 국밥은 맛있었다.

국물이 너무 무겁지도 않으면서도 진득했다.

완성도가 높다. 국물은 손가락에 꼽을 정도로 괜찮았다.

고기의 품질도 좋았고 손질도 잘했다. 부드럽고 또 비계 부분은 쫄깃했다.

다만 고기가 많아 보이지만 한 점 한 점이 굉장히 얇아서 무게로 치면 그렇게 많은 양은 아닐 것이다.

그리고 국밥 전체적으로 양은 그다지 많은 편은 아니다.

이 정도의 맛이면 미쉐린 빕구르망에 선정되는 것인지는 모르겠지만 나로서는 충분하다고 느껴진다.

내가 추구하는 수더분하고 푸짐한 국밥하고는 반대편에 있는 국밥이지만 완성도가 높으니 다 괜찮아 보인다.

좀 편하게 갈 수 있다면 가끔 가고 싶다.

서면과 부산역에 분점이 있다고 하니 그곳이 좀 편하겠다.

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ + +
+
+ +
+
+
+
+

+
+
+
+ +
""", + "title": "안목", + "summary": "국밥 맛집 홍보" + }, + { + "raw_html": """
+
+
+
+
+ + + +
+
+
+
+
+
+
+

서울 미쉐린맛집 한식전문 목멱산방

-투쁠한우 육회비빔밥

-검은깨두부 보쌈

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

서울 중구 퇴계로20길 71

영업시간

매일

11:00 - 20:00

라스트오더

19:20

전화

02-318-4790

+
+
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

서울 남산은 참 묘한 매력이 있는 곳 같아요!

도시 속인데도 한 발짝만 올라오면

바람도 다르고, 공기도 다르고,

마음까지 탁 트이는 그런 느낌!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

그런 남산 한켠에 있는

서울 미쉐린 맛집

목멱산방 본점에서

특별한 한 끼를 즐기고 왔어요!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

식사 중간중간 보니 외국인 관광객도 많았고

데이트나 가족 외식으로 많이들 오더라고요~

실내는 군더더기 없이 깔끔하고

모던한 느낌이라

전통 한식을

더 세련되게 느낄 수 있어요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

주문은 셀프 방식으로

키오스크로 하면돼요~

방송에도 여러번 나오고

미쉐린 맛집답게

주말에는 사람이 많아요!

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이날 저희가 선택한 메뉴는

검은깨두부와 보쌈,

그리고

시그니처 메뉴인

투뿔한우 육회비빔밥을

주문했는데

기대 이상이었어요!

+
+
+
+ +
+
+
+
+

검은깨두부&보쌈

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

먼저 검은깨두부와 보쌈!!

검은깨 두부는

보기만 해도

고소한 향이 물씬 풍기는것같고

입에 넣자마자 사르르 녹아요!!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

정말 진한 고소함이 입안에 퍼지는데,

이게 그냥 두부가 아니라는 걸

한입만 먹어도 느낄 수 있어요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

그 두부와 함께 나오는 보쌈은

지방과 살코기 비율이 완벽해서

쫀득하면서도 부드러워요.

거기에 곁들여지는

볶음김치와 특제 야채무침이

보쌈 맛을 확 살려줘서,

딱 한식의 진수라는 말이

떠오르더라고요!

+
+
+
+ +
+
+
+
+

투쁠한우 육회비빔밥

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

대망의 투쁠한우 육회비빔밥!

비주얼도 예쁘고

정말 먹음직 스러웠어요!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이건 먼저 육회만 따로 맛봤는데,

신선한 투뿔 채끝살에

유자청과 꿀로 살짝 단맛을 더한

양념이 어우러져,

하나도 느끼하지 않고 깔끔했어요.!!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비빔밥은 나물과 함께

조심스럽게 비벼 한입 먹었을 때,

고추장을 넣지 않고도

양념된 육회와 참기름만으로

깊은 맛이 나는 게,

정말 재료 하나하나에

얼마나 정성을 들였는지

알겠더라고요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비빔밥 안에 들어가는 나물도

건나물, 생야채, 표고버섯,

도라지, 고사리 등

제철에 맞춰 엄선된

나물들이 들어가는데,

하나하나 다 본연의 맛이 좋았어요~

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

삼광쌀로 지은 밥도 맛있더라구요~

밥 한 숟가락에

입안이 꽉 차는 느낌이 넘 좋았어요!

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

함께 주문하고 싶은 사이드 메뉴는

바로 치즈김치전!

피자치즈와 모짜렐라가

가득 들어간 김치전인데,

겉은 바삭하고 속은 촉촉한 게

비빔밥이랑 궁합 최고예요.

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

술 한잔 곁들이고 싶다면,

비빔밥 전용 막걸리도 있어요.

‘한 잔 막걸리’라는 이름답게

식전–식중–식후로 나눠 마시는 재미가 있어요.

과일향도 은은하고,

단맛과 신맛이 균형 잡혀 있어서

비빔밥과 찰떡이에요.

남산 산책하다가,

혹은 명동역 근처로

들리기 좋은 곳이랍니다^^

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ +
+
+ +
+
+
+
+

+
+
+
+ +
""", + "title": "목멱산방", + "summary": "한식 맛집 홍보" + } + ] + # 인스타 글 예시 + self.insta_example = [ + { + "caption": """힘든 월요일 잘 이겨내신 여러분~~~ + 소나기도 내리고 힘드셨을텐데 + 오늘 하루 고생 많으셨어요~~^^ + 고생한 나를 위해 시원한 맥주에 + 낙곱새~~기가 막히죠??낙지에 대창올리고 + 그 위에 새우~화룡점정으로 생와사비~ + 그 맛은 뭐 말씀 안드려도 여러분들이 + 더 잘 아실거예요~~그럼 다들 낙곱새 고고~~""", + "title": "국민 낙곱새", + "summary": "낙곱새 맛집 홍보" + }, + { + "caption": """안녕하세요! 타코몰리김포점입니다! + 타코몰리는 멕시코 문화와 풍부한맛을 경험할 수 있는 특별한 공간입니다.🎉 + + 🌶 대표 메뉴를 맛보세요 + 수제 타코, 바삭한 퀘사디아, 풍성한 부리또로 다양한 맛을 즐길 수 있습니다. + + 📸 특별한 순간을 담아보세요 + #타코몰리김포 해시태그와 함께 여러분의 멋진 사진을 공유해주세요. + 이벤트가 기다리고 있답니다!! + (새우링/치즈스틱/음료 택1) + + 📍 위치 + 김포한강 11로 140번길 15-2 + + 멕시코의 맛과 전통에 푹 빠져보세요! + 언제든지 여러분을 기다리고 있겠습니다🌟""", + "title": "타코몰리", + "summary": "멕시칸 맛집 홍보" + }, + { + "caption":"""📣명륜진사갈비 신메뉴 3종 출시! + + 특제 고추장 양념에 마늘과 청양고추를 더해 + 매콤한 불맛이 일품인 #매콤불고기 🌶️ + + 특제 간장 양념에 마늘과 청양고추를 더해 + 달콤한 감칠맛이 있는 #달콤불고기 🍯 + + 갈비뼈에 붙어있는 부위로 일반 삼겹살보다 + 더욱 깊은 맛과 풍미를 가진 #삼겹갈비 까지🍖 + + 신메뉴로 더욱 풍성해진 명륜진사갈비에서 + 연말 가족/단체모임을 즐겨보세요! + + ※ 신메뉴는 지점에 따라 탄력적으로 운영되고 있으니, + 자세한 문의는 방문하실 매장으로 확인 부탁드립니다.""", + "title": "명륜진사갈비", + "summary": "갈비 맛집 홍보" + } + ] + + # 플랫폼별 콘텐츠 특성 정의 (대폭 개선) + self.platform_specs = { + '인스타그램': { + 'max_length': 2200, + 'hashtag_count': 15, + 'style': '감성적이고 시각적', + 'format': '짧은 문장, 해시태그 활용', + 'content_structure': '후킹 문장 → 스토리텔링 → 행동 유도 → 해시태그', + 'writing_tips': [ + '첫 문장으로 관심 끌기', + '이모티콘을 적절히 활용', + '줄바꿈으로 가독성 높이기', + '개성 있는 말투 사용', + '팔로워와의 소통 유도' + ], + 'hashtag_strategy': [ + '브랜딩 해시태그 포함', + '지역 기반 해시태그', + '트렌딩 해시태그 활용', + '음식 관련 인기 해시태그', + '감정 표현 해시태그' + ], + 'call_to_action': ['팔로우', '댓글', '저장', '공유', '방문'] + }, + '네이버 블로그': { + 'max_length': 3000, + 'hashtag_count': 10, + 'style': '정보성과 친근함', + 'format': '구조화된 내용, 상세 설명', + 'content_structure': '제목 → 인트로 → 본문(구조화) → 마무리', + 'writing_tips': [ + '검색 키워드 자연스럽게 포함', + '단락별로 소제목 활용', + '구체적인 정보 제공', + '후기/리뷰 형식 활용', + '지역 정보 상세히 기술' + ], + 'seo_keywords': [ + '맛집', '리뷰', '추천', '후기', + '메뉴', '가격', '위치', '분위기', + '데이트', '모임', '가족', '혼밥' + ], + 'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'], + 'image_placement_strategy': [ + '매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기', + '텍스트 2-3문장마다 이미지 배치', + '이미지 설명은 간결하고 매력적으로', + '마지막에 대표 이미지로 마무리' + ] + } + } + + # 톤앤매너별 스타일 (플랫폼별 세분화) + # self.tone_styles = { + # '친근한': { + # '인스타그램': '반말, 친구같은 느낌, 이모티콘 많이 사용', + # '네이버 블로그': '존댓말이지만 따뜻하고 친근한 어조' + # }, + # '정중한': { + # '인스타그램': '정중하지만 접근하기 쉬운 어조', + # '네이버 블로그': '격식 있고 신뢰감 있는 리뷰 스타일' + # }, + # '재미있는': { + # '인스타그램': '유머러스하고 트렌디한 표현', + # '네이버 블로그': '재미있는 에피소드가 포함된 후기' + # }, + # '전문적인': { + # '인스타그램': '전문성을 어필하되 딱딱하지 않게', + # '네이버 블로그': '전문가 관점의 상세한 분석과 평가' + # } + # } + + # 카테고리별 플랫폼 특화 키워드 + self.category_keywords = { + '음식': { + '인스타그램': ['#맛스타그램', '#음식스타그램', '#먹스타그램', '#맛집', '#foodstagram'], + '네이버 블로그': ['맛집 리뷰', '음식 후기', '메뉴 추천', '맛집 탐방', '식당 정보'] + }, + '매장': { + '인스타그램': ['#카페스타그램', '#인테리어', '#분위기맛집', '#데이트장소'], + '네이버 블로그': ['카페 추천', '분위기 좋은 곳', '인테리어 구경', '모임장소'] + }, + '이벤트': { + '인스타그램': ['#이벤트', '#프로모션', '#할인', '#특가'], + '네이버 블로그': ['이벤트 소식', '할인 정보', '프로모션 안내', '특별 혜택'] + } + } + + # 감정 강도별 표현 + # self.emotion_levels = { + # '약함': '은은하고 차분한 표현', + # '보통': '적당히 활기찬 표현', + # '강함': '매우 열정적이고 강렬한 표현' + # } + + # 이미지 타입 분류를 위한 키워드 + self.image_type_keywords = { + '매장외관': ['외관', '건물', '간판', '입구', '외부'], + '인테리어': ['내부', '인테리어', '좌석', '테이블', '분위기', '장식'], + '메뉴판': ['메뉴', '가격', '메뉴판', '메뉴보드', 'menu'], + '음식': ['음식', '요리', '메뉴', '디저트', '음료', '플레이팅'], + '사람': ['사람', '고객', '직원', '사장', '요리사'], + '기타': ['기타', '일반', '전체'] + } + + def generate_sns_content(self, request: SnsContentGetRequest) -> Dict[str, Any]: + """ + SNS 콘텐츠 생성 (플랫폼별 특화) + """ + try: + # 이미지 다운로드 및 분석 + image_analysis = self._analyze_images_from_urls(request.images) + + # 네이버 블로그인 경우 이미지 배치 계획 생성 + image_placement_plan = None + if request.platform == '네이버 블로그': + image_placement_plan = self._create_image_placement_plan(image_analysis, request) + + # 플랫폼별 특화 프롬프트 생성 + prompt = self._create_platform_specific_prompt(request, image_analysis, image_placement_plan) + + # blog_example을 프롬프트에 추가 + if request.platform == '네이버 블로그' and hasattr(self, 'blog_example') and self.blog_example: + prompt += f"\n\n**참고 예시:**\n{str(self.blog_example)}\n위 예시를 참고하여 점주의 입장에서 가게 홍보 게시물을 작성해주세요." + elif hasattr(self, 'insta_example') and self.insta_example : + prompt += f"\n\n**참고 예시:**\n{str(self.insta_example)}\n위 예시를 참고하여 점주의 입장에서 가게 홍보 게시물을 작성해주세요." + + # AI로 콘텐츠 생성 + generated_content = self.ai_client.generate_text(prompt, max_tokens=1500) + + # 플랫폼별 후처리 + processed_content = self._post_process_content(generated_content, request) + + # HTML 형식으로 포맷팅 + html_content = self._format_to_html(processed_content, request, image_placement_plan) + + result = { + 'success': True, + 'content': html_content + } + + # 네이버 블로그인 경우 이미지 배치 가이드라인 추가 + if request.platform == '네이버 블로그' and image_placement_plan: + result['image_placement_guide'] = image_placement_plan + + return result + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } + + def _analyze_images_from_urls(self, image_urls: list) -> Dict[str, Any]: + """ + URL에서 이미지를 다운로드하고 분석 (이미지 타입 분류 추가) + """ + analysis_results = [] + temp_files = [] + + try: + for i, image_url in enumerate(image_urls): + # 이미지 다운로드 + temp_path = self.ai_client.download_image_from_url(image_url) + if temp_path: + temp_files.append(temp_path) + + # 이미지 분석 + try: + image_info = self.image_processor.get_image_info(temp_path) + image_description = self.ai_client.analyze_image(temp_path) + + # 이미지 타입 분류 + image_type = self._classify_image_type(image_description) + + analysis_results.append({ + 'index': i, + 'url': image_url, + 'info': image_info, + 'description': image_description, + 'type': image_type + }) + except Exception as e: + analysis_results.append({ + 'index': i, + 'url': image_url, + 'error': str(e), + 'type': '기타' + }) + + return { + 'total_images': len(image_urls), + 'results': analysis_results + } + + finally: + # 임시 파일 정리 + for temp_file in temp_files: + try: + os.remove(temp_file) + except: + pass + + def _classify_image_type(self, description: str) -> str: + """ + 이미지 설명을 바탕으로 이미지 타입 분류 + """ + description_lower = description.lower() + + for image_type, keywords in self.image_type_keywords.items(): + for keyword in keywords: + if keyword in description_lower: + return image_type + + return '기타' + + def _create_image_placement_plan(self, image_analysis: Dict[str, Any], request: SnsContentGetRequest) -> Dict[ + str, Any]: + """ + 네이버 블로그용 이미지 배치 계획 생성 + """ + images = image_analysis.get('results', []) + if not images: + return None + + # 이미지 타입별 분류 + categorized_images = { + '매장외관': [], + '인테리어': [], + '메뉴판': [], + '음식': [], + '사람': [], + '기타': [] + } + + for img in images: + img_type = img.get('type', '기타') + categorized_images[img_type].append(img) + + # 블로그 구조에 따른 이미지 배치 계획 + placement_plan = { + 'structure': [ + { + 'section': '인트로', + 'description': '첫인상과 방문 동기', + 'recommended_images': [], + 'placement_guide': '매장 외관이나 대표적인 음식 사진으로 시작' + }, + { + 'section': '매장 정보', + 'description': '위치, 분위기, 인테리어 소개', + 'recommended_images': [], + 'placement_guide': '매장 외관 → 내부 인테리어 순서로 배치' + }, + { + 'section': '메뉴 소개', + 'description': '주문한 메뉴와 상세 후기', + 'recommended_images': [], + 'placement_guide': '메뉴판 → 실제 음식 사진 순서로 배치' + }, + { + 'section': '총평', + 'description': '재방문 의향과 추천 이유', + 'recommended_images': [], + 'placement_guide': '가장 매력적인 음식 사진이나 전체 분위기 사진' + } + ], + 'image_sequence': [], + 'usage_guide': [] + } + + # 각 섹션에 적절한 이미지 배정 + # 인트로: 매장외관 또는 대표 음식 + if categorized_images['매장외관']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1]) + elif categorized_images['음식']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['음식'][:1]) + + # 매장 정보: 외관 + 인테리어 + placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관']) + placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어']) + + # 메뉴 소개: 메뉴판 + 음식 + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판']) + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식']) + + # 총평: 남은 음식 사진 또는 기타 + remaining_food = [img for img in categorized_images['음식'] + if img not in placement_plan['structure'][2]['recommended_images']] + placement_plan['structure'][3]['recommended_images'].extend(remaining_food[:1]) + placement_plan['structure'][3]['recommended_images'].extend(categorized_images['기타'][:1]) + + # 전체 이미지 순서 생성 + for section in placement_plan['structure']: + for img in section['recommended_images']: + if img not in placement_plan['image_sequence']: + placement_plan['image_sequence'].append(img) + + # 사용 가이드 생성 + placement_plan['usage_guide'] = [ + "📸 이미지 배치 가이드라인:", + "1. 각 섹션마다 2-3문장의 설명 후 이미지 삽입", + "2. 이미지마다 간단한 설명 텍스트 추가", + "3. 음식 사진은 가장 맛있어 보이는 각도로 배치", + "4. 마지막에 전체적인 분위기를 보여주는 사진으로 마무리" + ] + + return placement_plan + + def _create_platform_specific_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any], + image_placement_plan: Dict[str, Any] = None) -> str: + """ + 플랫폼별 특화 프롬프트 생성 + """ + platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램']) + #tone_style = self.tone_styles.get(request.toneAndManner, {}).get(request.platform, '친근하고 자연스러운 어조') + + # 이미지 설명 추출 + image_descriptions = [] + for result in image_analysis.get('results', []): + if 'description' in result: + image_descriptions.append(result['description']) + + # 플랫폼별 특화 프롬프트 생성 + if request.platform == '인스타그램': + return self._create_instagram_prompt(request, platform_spec, image_descriptions) + elif request.platform == '네이버 블로그': + return self._create_naver_blog_prompt(request, platform_spec, image_descriptions, + image_placement_plan) + else: + return self._create_instagram_prompt(request, platform_spec, image_descriptions) + + def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, + image_descriptions: list) -> str: + """ + 인스타그램 특화 프롬프트 + """ + category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', []) + + prompt = f""" +당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요. +**🍸 가게 정보:** +- 가게명: {request.storeName} +- 업종 : {request.storeType} + +**🎯 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '특별 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} +- 독자층: {request.target} + +**📱 인스타그램 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- 해시태그: {platform_spec['hashtag_count']}개 내외 + +**✨ 인스타그램 작성 가이드라인:** +{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} + +**📸 이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '시각적으로 매력적인 음식/매장 이미지'} + +**🏷️ 추천 해시태그 카테고리:** +- 기본 해시태그: {', '.join(category_hashtags[:5])} +- 브랜딩: #우리가게이름 (실제 가게명으로 대체) +- 지역: #강남맛집 #서울카페 (실제 위치로 대체) +- 감정: #행복한시간 #맛있다 #추천해요 + +**💡 콘텐츠 작성 지침:** +1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작 +2. 이모티콘을 적절히 활용하여 시각적 재미 추가 +3. 스토리텔링을 통해 감정적 연결 유도 +4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등) +5. 줄바꿈을 활용하여 가독성 향상 +6. 해시태그는 본문과 자연스럽게 연결되도록 배치 + +**필수 요구사항:** +{request.requirement #or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' +} + +인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요. +필수 요구사항을 반드시 참고하여 작성해주세요. +""" + return prompt + + def _create_naver_blog_prompt(self, request: SnsContentGetRequest, platform_spec: dict, + image_descriptions: list, image_placement_plan: Dict[str, Any]) -> str: + """ + 네이버 블로그 특화 프롬프트 (이미지 배치 계획 포함) + """ + category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', []) + seo_keywords = platform_spec['seo_keywords'] + + # 이미지 배치 정보 추가 + image_placement_info = "" + if image_placement_plan: + image_placement_info = f""" + +**📸 이미지 배치 계획:** +{chr(10).join([f"- {section['section']}: {section['placement_guide']}" for section in image_placement_plan['structure']])} + +**이미지 사용 순서:** +{chr(10).join([f"{i + 1}. {img.get('description', 'Image')} (타입: {img.get('type', '기타')})" for i, img in enumerate(image_placement_plan.get('image_sequence', []))])} +""" + + prompt = f""" +당신은 네이버 블로그 맛집 리뷰 전문가입니다. 검색 최적화와 정보 제공을 중시하는 네이버 블로그 특성에 맞는 게시물을 작성해주세요. + +**🍸 가게 정보:** +- 가게명: {request.storeName} +- 업종 : {request.storeType} + +**📝 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '대표 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} +- 독자층: {request.target} + +**🔍 네이버 블로그 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- SEO 최적화 필수 + +**📚 블로그 작성 가이드라인:** +{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])} + +**🖼️ 이미지 분석 결과:** +{chr(10).join(image_descriptions) if image_descriptions else '상세한 음식/매장 정보'} + +{image_placement_info} + +**🔑 SEO 키워드 (자연스럽게 포함할 것):** +- 필수 키워드: {', '.join(seo_keywords[:8])} +- 카테고리 키워드: {', '.join(category_keywords[:5])} + +**💡 콘텐츠 작성 지침:** +1. 검색자의 궁금증을 해결하는 정보 중심 작성 +2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함 +3. 개인적인 경험과 솔직한 후기 작성 +4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시 +5. 이미지마다 간단한 설명 문구 추가 +6. 지역 정보와 접근성 정보 포함 + +**이미지 태그 사용법:** +- [IMAGE_1]: 첫 번째 이미지 배치 위치 +- [IMAGE_2]: 두 번째 이미지 배치 위치 +- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 + +**필수 요구사항:** +{request.requirement + # or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' +} + +네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. +필수 요구사항을 반드시 참고하여 작성해주세요. +이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요. +""" + return prompt + + def _post_process_content(self, content: str, request: SnsContentGetRequest) -> str: + """ + 플랫폼별 후처리 + """ + if request.platform == '인스타그램': + return self._post_process_instagram(content, request) + elif request.platform == '네이버 블로그': + return self._post_process_naver_blog(content, request) + return content + + def _post_process_instagram(self, content: str, request: SnsContentGetRequest) -> str: + """ + 인스타그램 콘텐츠 후처리 + """ + import re + + # 해시태그 개수 조정 + hashtags = re.findall(r'#[\w가-힣]+', content) + if len(hashtags) > 15: + # 해시태그가 너무 많으면 중요도 순으로 15개만 유지 + all_hashtags = ' '.join(hashtags[:15]) + content = re.sub(r'#[\w가-힣]+', '', content) + content = content.strip() + '\n\n' + all_hashtags + + # 이모티콘이 부족하면 추가 + emoji_count = content.count('😊') + content.count('🍽️') + content.count('❤️') + content.count('✨') + if emoji_count < 3: + content = content.replace('!', '! 😊', 1) + + return content + + def _post_process_naver_blog(self, content: str, request: SnsContentGetRequest) -> str: + """ + 네이버 블로그 콘텐츠 후처리 + """ + # 구조화된 형태로 재구성 + if '📍' not in content and '🏷️' not in content: + # 이모티콘 기반 구조화가 없으면 추가 + lines = content.split('\n') + structured_content = [] + for line in lines: + if '위치' in line or '주소' in line: + line = f"📍 {line}" + elif '가격' in line or '메뉴' in line: + line = f"🏷️ {line}" + elif '분위기' in line or '인테리어' in line: + line = f"🏠 {line}" + structured_content.append(line) + content = '\n'.join(structured_content) + + return content + + def _format_to_html(self, content: str, request: SnsContentGetRequest, + image_placement_plan: Dict[str, Any] = None) -> str: + """ + 생성된 콘텐츠를 HTML 형식으로 포맷팅 (이미지 배치 포함) + """ + # 1. literal \n 문자열을 실제 줄바꿈으로 변환 + content = content.replace('\\n', '\n') + + # 2. 인스타그램인 경우 첫 번째 이미지를 맨 위에 배치 ⭐ 새로 추가! + images_html_content = "" + if request.platform == '인스타그램' and request.images and len(request.images) > 0: + # 모든 이미지를 통일된 크기로 HTML 변환 (한 줄로 작성!) + for i, image_url in enumerate(request.images): + # ⭐ 핵심: 모든 HTML을 한 줄로 작성해서
변환 문제 방지 + image_html = f'
이미지 {i + 1}
' + images_html_content += image_html + "\n" + + # 이미지를 콘텐츠 맨 앞에 추가 + content = images_html_content + content + + # 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환 + elif request.platform == '네이버 블로그' and image_placement_plan: + content = self._replace_image_tags_with_html(content, image_placement_plan, request.images) + + # 3. 실제 줄바꿈을
태그로 변환 + content = content.replace('\n', '
') + + # 4. 추가 정리: \r, 여러 공백 정리 + content = content.replace('\\r', '').replace('\r', '') + + # 6. 여러 개의
태그를 하나로 정리 + import re + content = re.sub(r'(
\s*){3,}', '

', content) + + # 7. ⭐ 간단한 해시태그 스타일링 (CSS 충돌 방지) + import re + # style="..." 패턴을 먼저 찾아서 보호 + style_patterns = re.findall(r'style="[^"]*"', content) + protected_content = content + + for i, pattern in enumerate(style_patterns): + protected_content = protected_content.replace(pattern, f'___STYLE_{i}___') + + # 이제 안전하게 해시태그 스타일링 + protected_content = re.sub(r'(#[\w가-힣]+)', r'\1', + protected_content) + + # 보호된 스타일 복원 + for i, pattern in enumerate(style_patterns): + protected_content = protected_content.replace(f'___STYLE_{i}___', pattern) + + content = protected_content + + # 플랫폼별 헤더 스타일 + platform_style = "" + if request.platform == '인스타그램': + platform_style = "background: linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);" + elif request.platform == '네이버 블로그': + platform_style = "background: linear-gradient(135deg, #1EC800 0%, #00B33C 100%);" + else: + platform_style = "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);" + + # 전체 HTML 구조 + html_content = f""" +
+
+

{request.platform} 게시물

+
+
+
+ {content} +
+ {self._add_metadata_html(request)} +
+
+ """ + return html_content + + def _replace_image_tags_with_html(self, content: str, image_placement_plan: Dict[str, Any], + image_urls: List[str]) -> str: + """ + 네이버 블로그 콘텐츠의 [IMAGE_X] 태그를 실제 이미지 HTML로 변환 + """ + import re + + # [IMAGE_X] 패턴 찾기 + image_tags = re.findall(r'\[IMAGE_(\d+)\]', content) + + for tag in image_tags: + image_index = int(tag) - 1 # 1-based to 0-based + + if image_index < len(image_urls): + image_url = image_urls[image_index] + + # 이미지 배치 계획에서 해당 이미지 정보 찾기 + image_info = None + for img in image_placement_plan.get('image_sequence', []): + if img.get('index') == image_index: + image_info = img + break + + # 이미지 설명 생성 + image_description = "" + if image_info: + description = image_info.get('description', '') + img_type = image_info.get('type', '기타') + + if img_type == '음식': + image_description = f"😋 {description}" + elif img_type == '매장외관': + image_description = f"🏪 {description}" + elif img_type == '인테리어': + image_description = f"🏠 {description}" + elif img_type == '메뉴판': + image_description = f"📋 {description}" + else: + image_description = f"📸 {description}" + + # HTML 이미지 태그로 변환 + image_html = f""" +
+ 이미지 +
+ {image_description} +
+
""" + + # 콘텐츠에서 태그 교체 + content = content.replace(f'[IMAGE_{tag}]', image_html) + + return content + + def _add_metadata_html(self, request: SnsContentGetRequest) -> str: + """ + 메타데이터를 HTML에 추가 + """ + metadata_html = '
' + + if request.menuName: + metadata_html += f'
메뉴: {request.menuName}
' + + if request.eventName: + metadata_html += f'
이벤트: {request.eventName}
' + + if request.startDate and request.endDate: + metadata_html += f'
기간: {request.startDate} ~ {request.endDate}
' + + metadata_html += f'
카테고리: {request.category}
' + metadata_html += f'
플랫폼: {request.platform}
' + metadata_html += f'
생성일: {datetime.now().strftime("%Y-%m-%d %H:%M")}
' + metadata_html += '
' + + return metadata_html diff --git a/smarketing-ai/test.py b/smarketing-ai/test.py new file mode 100644 index 0000000..019f76a --- /dev/null +++ b/smarketing-ai/test.py @@ -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() \ No newline at end of file diff --git a/smarketing-ai/utils/__init__.py b/smarketing-ai/utils/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/utils/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py new file mode 100644 index 0000000..68700d3 --- /dev/null +++ b/smarketing-ai/utils/ai_client.py @@ -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 "안녕하세요! 우리 가게를 찾아주셔서 감사합니다." diff --git a/smarketing-ai/utils/blob_storage.py b/smarketing-ai/utils/blob_storage.py new file mode 100644 index 0000000..3018a8b --- /dev/null +++ b/smarketing-ai/utils/blob_storage.py @@ -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 \ No newline at end of file diff --git a/smarketing-ai/utils/image_processor.py b/smarketing-ai/utils/image_processor.py new file mode 100644 index 0000000..176c10a --- /dev/null +++ b/smarketing-ai/utils/image_processor.py @@ -0,0 +1,166 @@ +""" +이미지 처리 유틸리티 +이미지 분석, 변환, 최적화 기능 제공 +""" +import os +from typing import Dict, Any, Tuple +from PIL import Image, ImageOps +import io +class ImageProcessor: + """이미지 처리 클래스""" + def __init__(self): + """이미지 프로세서 초기화""" + self.supported_formats = {'JPEG', 'PNG', 'WEBP', 'GIF'} + self.max_size = (2048, 2048) # 최대 크기 + self.thumbnail_size = (400, 400) # 썸네일 크기 + def get_image_info(self, image_path: str) -> Dict[str, Any]: + """ + 이미지 기본 정보 추출 + Args: + image_path: 이미지 파일 경로 + Returns: + 이미지 정보 딕셔너리 + """ + try: + with Image.open(image_path) as image: + info = { + 'filename': os.path.basename(image_path), + 'format': image.format, + 'mode': image.mode, + 'size': image.size, + 'width': image.width, + 'height': image.height, + 'file_size': os.path.getsize(image_path), + 'aspect_ratio': round(image.width / image.height, 2) if image.height > 0 else 0 + } + # 이미지 특성 분석 + info['is_landscape'] = image.width > image.height + info['is_portrait'] = image.height > image.width + info['is_square'] = abs(image.width - image.height) < 50 + return info + except Exception as e: + return { + 'filename': os.path.basename(image_path), + 'error': str(e) + } + def resize_image(self, image_path: str, target_size: Tuple[int, int], + maintain_aspect: bool = True) -> Image.Image: + """ + 이미지 크기 조정 + Args: + image_path: 원본 이미지 경로 + target_size: 목표 크기 (width, height) + maintain_aspect: 종횡비 유지 여부 + Returns: + 리사이즈된 PIL 이미지 + """ + try: + with Image.open(image_path) as image: + if maintain_aspect: + # 종횡비 유지하며 리사이즈 + image.thumbnail(target_size, Image.Resampling.LANCZOS) + return image.copy() + else: + # 강제 리사이즈 + return image.resize(target_size, Image.Resampling.LANCZOS) + except Exception as e: + raise Exception(f"이미지 리사이즈 실패: {str(e)}") + def optimize_image(self, image_path: str, quality: int = 85) -> bytes: + """ + 이미지 최적화 (파일 크기 줄이기) + Args: + image_path: 원본 이미지 경로 + quality: JPEG 품질 (1-100) + Returns: + 최적화된 이미지 바이트 + """ + try: + with Image.open(image_path) as image: + # RGBA를 RGB로 변환 (JPEG 저장을 위해) + if image.mode == 'RGBA': + background = Image.new('RGB', image.size, (255, 255, 255)) + background.paste(image, mask=image.split()[-1]) + image = background + # 크기가 너무 크면 줄이기 + if image.width > self.max_size[0] or image.height > self.max_size[1]: + image.thumbnail(self.max_size, Image.Resampling.LANCZOS) + # 바이트 스트림으로 저장 + img_buffer = io.BytesIO() + image.save(img_buffer, format='JPEG', quality=quality, optimize=True) + return img_buffer.getvalue() + except Exception as e: + raise Exception(f"이미지 최적화 실패: {str(e)}") + def create_thumbnail(self, image_path: str, size: Tuple[int, int] = None) -> Image.Image: + """ + 썸네일 생성 + Args: + image_path: 원본 이미지 경로 + size: 썸네일 크기 (기본값: self.thumbnail_size) + Returns: + 썸네일 PIL 이미지 + """ + if size is None: + size = self.thumbnail_size + try: + with Image.open(image_path) as image: + # 정사각형 썸네일 생성 + thumbnail = ImageOps.fit(image, size, Image.Resampling.LANCZOS) + return thumbnail + except Exception as e: + raise Exception(f"썸네일 생성 실패: {str(e)}") + def analyze_colors(self, image_path: str, num_colors: int = 5) -> list: + """ + 이미지의 주요 색상 추출 + Args: + image_path: 이미지 파일 경로 + num_colors: 추출할 색상 개수 + Returns: + 주요 색상 리스트 [(R, G, B), ...] + """ + try: + with Image.open(image_path) as image: + # RGB로 변환 + if image.mode != 'RGB': + image = image.convert('RGB') + # 이미지 크기 줄여서 처리 속도 향상 + image.thumbnail((150, 150)) + # 색상 히스토그램 생성 + colors = image.getcolors(maxcolors=256*256*256) + if colors: + # 빈도순으로 정렬 + colors.sort(key=lambda x: x[0], reverse=True) + # 상위 색상들 반환 + dominant_colors = [] + for count, color in colors[:num_colors]: + dominant_colors.append(color) + return dominant_colors + return [(128, 128, 128)] # 기본 회색 + except Exception as e: + print(f"색상 분석 실패: {e}") + return [(128, 128, 128)] # 기본 회색 + def is_food_image(self, image_path: str) -> bool: + """ + 음식 이미지 여부 간단 판별 + (실제로는 AI 모델이 필요하지만, 여기서는 기본적인 휴리스틱 사용) + Args: + image_path: 이미지 파일 경로 + Returns: + 음식 이미지 여부 + """ + try: + # 파일명에서 키워드 확인 + filename = os.path.basename(image_path).lower() + food_keywords = ['food', 'meal', 'dish', 'menu', '음식', '메뉴', '요리'] + for keyword in food_keywords: + if keyword in filename: + return True + # 색상 분석으로 간단 판별 (음식은 따뜻한 색조가 많음) + colors = self.analyze_colors(image_path, 3) + warm_color_count = 0 + for r, g, b in colors: + # 따뜻한 색상 (빨강, 노랑, 주황 계열) 확인 + if r > 150 or (r > g and r > b): + warm_color_count += 1 + return warm_color_count >= 2 + except: + return False \ No newline at end of file diff --git a/smarketing-java/.gitignore b/smarketing-java/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/smarketing-java/.gitignore @@ -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/ diff --git a/smarketing-java/ai-recommend/build.gradle b/smarketing-java/ai-recommend/build.gradle new file mode 100644 index 0000000..771a2fc --- /dev/null +++ b/smarketing-java/ai-recommend/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java new file mode 100644 index 0000000..c331ea3 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.recommend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication(scanBasePackages = { + "com.won.smarketing.recommend", + "com.won.smarketing.common" +}) +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence") +@EnableCaching +public class AIRecommendServiceApplication { + public static void main(String[] args) { + SpringApplication.run(AIRecommendServiceApplication.class, args); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java new file mode 100644 index 0000000..49b2801 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -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 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 findRecentMarketingTip(Long storeId) { + log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId); + + // 최근 생성된 팁 1개 조회 + Pageable pageable = PageRequest.of(0, 1); + Page 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(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java new file mode 100644 index 0000000..209be1d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -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(); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java new file mode 100644 index 0000000..8dec201 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Configuration; + +/** + * 캐시 설정 + */ +@Configuration +@EnableCaching +public class CacheConfig { + // 기본 Simple 캐시 사용 +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java new file mode 100644 index 0000000..de705f5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java @@ -0,0 +1,12 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * JPA 설정 + */ +@Configuration +@EnableJpaRepositories +public class JpaConfig { +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java new file mode 100644 index 0000000..f860937 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java @@ -0,0 +1,29 @@ +package com.won.smarketing.recommend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; +import io.netty.channel.ChannelOption; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +/** + * WebClient 설정 (간소화된 버전) + */ +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .responseTimeout(Duration.ofMillis(30000)); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java new file mode 100644 index 0000000..28e503d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -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(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java new file mode 100644 index 0000000..2ec3eea --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java @@ -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; +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java new file mode 100644 index 0000000..efdcb8d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -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; +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java new file mode 100644 index 0000000..15ba3aa --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java @@ -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 menuDataList; +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java new file mode 100644 index 0000000..47808cb --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -0,0 +1,21 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 팁 ID 값 객체 + */ +@Getter +@EqualsAndHashCode +@NoArgsConstructor +@AllArgsConstructor +public class TipId { + private Long value; + + public static TipId of(Long value) { + return new TipId(value); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java new file mode 100644 index 0000000..ce0be77 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -0,0 +1,19 @@ +package com.won.smarketing.recommend.domain.repository; + +import com.won.smarketing.recommend.domain.model.MarketingTip; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +/** + * 마케팅 팁 레포지토리 인터페이스 (순수한 도메인 인터페이스) + */ +public interface MarketingTipRepository { + + MarketingTip save(MarketingTip marketingTip); + + Optional findById(Long tipId); + + Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java new file mode 100644 index 0000000..f3ea48e --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java @@ -0,0 +1,18 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; + +/** + * AI 팁 생성 도메인 서비스 인터페이스 (단순화) + */ +public interface AiTipGenerator { + + /** + * Python AI 서비스를 통한 마케팅 팁 생성 + * + * @param storeWithMenuData 매장 및 메뉴 정보 + * @return AI가 생성한 마케팅 팁 + */ + String generateTip(StoreWithMenuData storeWithMenuData); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java new file mode 100644 index 0000000..d7935e6 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -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); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java new file mode 100644 index 0000000..e091fc5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -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 menuDataList = storeWithMenuData.getMenuDataList(); + + // 메뉴 데이터를 Map 형태로 변환 + List> menuList = menuDataList.stream() + .map(menu -> { + Map 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 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; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java new file mode 100644 index 0000000..cc4f682 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -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 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 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 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 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 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 data; + private String message; + private boolean success; + + public List getData() { return data; } + public void setData(List 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; } + } + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java new file mode 100644 index 0000000..bcbda7e --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java @@ -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(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java new file mode 100644 index 0000000..c047eaa --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java @@ -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 { + + /** + * 매장별 마케팅 팁 조회 (기존 - storeId 기반) + */ + @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") + Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); + + /** + * 사용자별 마케팅 팁 조회 (새로 추가 - userId 기반) + */ + @Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC") + Page 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 findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId); + + /** + * 특정 팁이 해당 사용자의 것인지 확인 + */ + boolean existsByIdAndUserId(Long id, String userId); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java new file mode 100644 index 0000000..1ad2b9b --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java @@ -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 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 findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) { + // 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회 + String userId = getCurrentUserId(); + return findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + /** + * 사용자별 마케팅 팁 조회 (새로 추가) + */ + public Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) { + Page entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + // Store 정보는 한 번만 조회 (같은 userId이므로) + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId); + + return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData())); + } + + /** + * 사용자의 가장 최근 마케팅 팁 조회 + */ + public Optional 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(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java new file mode 100644 index 0000000..ad30482 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java @@ -0,0 +1,34 @@ +//package com.won.smarketing.recommend.presentation.controller; +// +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import java.time.LocalDateTime; +//import java.util.Map; +// +///** +// * 헬스체크 컨트롤러 +// */ +//@RestController +//public class HealthController { +// +// @GetMapping("/health") +// public Map health() { +// return Map.of( +// "status", "UP", +// "service", "ai-recommend-service", +// "timestamp", LocalDateTime.now(), +// "message", "AI 추천 서비스가 정상 동작 중입니다.", +// "features", Map.of( +// "store_integration", "매장 서비스 연동", +// "python_ai_integration", "Python AI 서비스 연동", +// "fallback_support", "Fallback 팁 생성 지원" +// ) +// ); +// } +//} +// } +// +// } catch (Exception e) { +// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); +// return createMockStoreData(storeId); \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java new file mode 100644 index 0000000..83a66f7 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -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> provideMarketingTip() { + + log.info("마케팅 팁 제공 요청"); + + MarketingTipResponse response = marketingTipUseCase.provideMarketingTip(); + + log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java new file mode 100644 index 0000000..d60ce97 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -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; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml new file mode 100644 index 0000000..88d3902 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -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} \ No newline at end of file diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle new file mode 100644 index 0000000..6c51f31 --- /dev/null +++ b/smarketing-java/build.gradle @@ -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() + } +} \ No newline at end of file diff --git a/smarketing-java/common/build.gradle b/smarketing-java/common/build.gradle new file mode 100644 index 0000000..b46abbb --- /dev/null +++ b/smarketing-java/common/build.gradle @@ -0,0 +1,23 @@ +bootJar { + enabled = false +} + +jar { + enabled = true + archiveClassifier = '' +} + +// 공통 의존성 재정의 (API 노출용) +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' +} \ No newline at end of file diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java new file mode 100644 index 0000000..a0bc038 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java @@ -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 redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java new file mode 100644 index 0000000..5c61143 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -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; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java new file mode 100644 index 0000000..fb21909 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java @@ -0,0 +1,43 @@ +package com.won.smarketing.common.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Swagger OpenAPI 설정 클래스 + * API 문서화 및 JWT 인증 설정 + */ +@Configuration +public class SwaggerConfig { + + /** + * OpenAPI 설정 + * + * @return OpenAPI 객체 + */ + @Bean + public OpenAPI openAPI() { + String jwtSchemeName = "jwtAuth"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .info(new Info() + .title("스마케팅 API") + .description("소상공인을 위한 AI 마케팅 서비스 API") + .version("1.0.0")) + .addSecurityItem(securityRequirement) + .components(components); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java new file mode 100644 index 0000000..dbb123b --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java @@ -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 응답 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "API 응답") +public class ApiResponse { + + @Schema(description = "응답 상태 코드", example = "200") + private int status; + + @Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.") + private String message; + + @Schema(description = "응답 데이터") + private T data; + + /** + * 성공 응답 생성 (데이터 포함) + * + * @param data 응답 데이터 + * @param 데이터 타입 + * @return 성공 응답 + */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .status(200) + .message("요청이 성공적으로 처리되었습니다.") + .data(data) + .build(); + } + + /** + * 성공 응답 생성 (데이터 및 메시지 포함) + * + * @param data 응답 데이터 + * @param message 응답 메시지 + * @param 데이터 타입 + * @return 성공 응답 + */ + public static ApiResponse success(T data, String message) { + return ApiResponse.builder() + .status(200) + .message(message) + .data(data) + .build(); + } + + /** + * 오류 응답 생성 + * + * @param status 오류 상태 코드 + * @param message 오류 메시지 + * @param 데이터 타입 + * @return 오류 응답 + */ + public static ApiResponse error(int status, String message) { + return ApiResponse.builder() + .status(status) + .message(message) + .data(null) + .build(); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java new file mode 100644 index 0000000..ab77b3f --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java @@ -0,0 +1,68 @@ +package com.won.smarketing.common.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 페이징 응답 DTO + * 페이징된 데이터 응답에 사용되는 공통 형식 + * + * @param 응답 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "페이징 응답") +public class PageResponse { + + @Schema(description = "페이지 컨텐츠", example = "[...]") + private List content; + + @Schema(description = "페이지 번호 (0부터 시작)", example = "0") + private int pageNumber; + + @Schema(description = "페이지 크기", example = "20") + private int pageSize; + + @Schema(description = "전체 요소 수", example = "100") + private long totalElements; + + @Schema(description = "전체 페이지 수", example = "5") + private int totalPages; + + @Schema(description = "첫 번째 페이지 여부", example = "true") + private boolean first; + + @Schema(description = "마지막 페이지 여부", example = "false") + private boolean last; + + /** + * 성공적인 페이징 응답 생성 + * + * @param content 페이지 컨텐츠 + * @param pageNumber 페이지 번호 + * @param pageSize 페이지 크기 + * @param totalElements 전체 요소 수 + * @param 데이터 타입 + * @return 페이징 응답 + */ + public static PageResponse of(List content, int pageNumber, int pageSize, long totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / pageSize); + + return PageResponse.builder() + .content(content) + .pageNumber(pageNumber) + .pageSize(pageSize) + .totalElements(totalElements) + .totalPages(totalPages) + .first(pageNumber == 0) + .last(pageNumber >= totalPages - 1) + .build(); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java new file mode 100644 index 0000000..9a8b7d6 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java @@ -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; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java new file mode 100644 index 0000000..be6bb93 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java @@ -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; +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d2da2b8 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,79 @@ +package com.won.smarketing.common.exception; + +import com.won.smarketing.common.dto.ApiResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; + +/** + * 전역 예외 처리기 + * 애플리케이션 전반의 예외를 통일된 형식으로 처리 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * 비즈니스 예외 처리 + * + * @param ex 비즈니스 예외 + * @return 오류 응답 + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + log.warn("Business exception occurred: {}", ex.getMessage()); + + return ResponseEntity + .status(ex.getErrorCode().getHttpStatus()) + .body(ApiResponse.error( + ex.getErrorCode().getHttpStatus().value(), + ex.getMessage() + )); + } + + /** + * 입력값 검증 예외 처리 + * + * @param ex 입력값 검증 예외 + * @return 오류 응답 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationException( + MethodArgumentNotValidException ex) { + log.warn("Validation exception occurred: {}", ex.getMessage()); + + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + return ResponseEntity.badRequest() + .body(ApiResponse.>builder() + .status(400) + .message("입력값 검증에 실패했습니다.") + .data(errors) + .build()); + } + + /** + * 일반적인 예외 처리 + * + * @param ex 예외 + * @return 오류 응답 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception ex) { + log.error("Unexpected exception occurred", ex); + + return ResponseEntity.internalServerError() + .body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다.")); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..16381bd --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java @@ -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; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..d88bc8e --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java @@ -0,0 +1,126 @@ +package com.won.smarketing.common.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; + +/** + * JWT 토큰 생성 및 검증을 담당하는 클래스 + * 액세스 토큰과 리프레시 토큰의 생성, 검증, 파싱 기능 제공 + */ +@Slf4j +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + /** + * -- GETTER -- + * 액세스 토큰 유효시간 반환 + * + * @return 액세스 토큰 유효시간 (밀리초) + */ + @Getter + private final long accessTokenValidityTime; + private final long refreshTokenValidityTime; + + /** + * JWT 토큰 프로바이더 생성자 + * + * @param secret JWT 서명에 사용할 비밀키 + * @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초) + * @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초) + */ + public JwtTokenProvider(@Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-validity}") long accessTokenValidityTime, + @Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + this.accessTokenValidityTime = accessTokenValidityTime; + this.refreshTokenValidityTime = refreshTokenValidityTime; + } + + /** + * 액세스 토큰 생성 + * + * @param userId 사용자 ID + * @return 생성된 액세스 토큰 + */ + public String generateAccessToken(String userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + accessTokenValidityTime); + + return Jwts.builder() + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + /** + * 리프레시 토큰 생성 + * + * @param userId 사용자 ID + * @return 생성된 리프레시 토큰 + */ + public String generateRefreshToken(String userId) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime); + + return Jwts.builder() + .subject(userId) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + /** + * 토큰에서 사용자 ID 추출 + * + * @param token JWT 토큰 + * @return 사용자 ID + */ + public String getUserIdFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.getSubject(); + } + + /** + * 토큰 유효성 검증 + * + * @param token 검증할 토큰 + * @return 유효성 여부 + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (SecurityException ex) { + log.error("Invalid JWT signature: {}", ex.getMessage()); + } catch (MalformedJwtException ex) { + log.error("Invalid JWT token: {}", ex.getMessage()); + } catch (ExpiredJwtException ex) { + log.error("Expired JWT token: {}", ex.getMessage()); + } catch (UnsupportedJwtException ex) { + log.error("Unsupported JWT token: {}", ex.getMessage()); + } catch (IllegalArgumentException ex) { + log.error("JWT claims string is empty: {}", ex.getMessage()); + } + return false; + } + +} \ No newline at end of file diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile new file mode 100644 index 0000000..b281bd7 --- /dev/null +++ b/smarketing-java/deployment/Jenkinsfile @@ -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 + """ + } + } + } +} diff --git a/smarketing-java/deployment/container/Dockerfile b/smarketing-java/deployment/container/Dockerfile new file mode 100644 index 0000000..be0f578 --- /dev/null +++ b/smarketing-java/deployment/container/Dockerfile @@ -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"] diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template new file mode 100644 index 0000000..4b88867 --- /dev/null +++ b/smarketing-java/deployment/deploy.yaml.template @@ -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 diff --git a/smarketing-java/deployment/deploy_env_vars b/smarketing-java/deployment/deploy_env_vars new file mode 100644 index 0000000..db95eda --- /dev/null +++ b/smarketing-java/deployment/deploy_env_vars @@ -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 diff --git a/smarketing-java/gradle/wrapper/gradle-wrapper.jar b/smarketing-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/smarketing-java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/smarketing-java/gradle/wrapper/gradle-wrapper.properties b/smarketing-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/smarketing-java/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/smarketing-java/gradlew b/smarketing-java/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/smarketing-java/gradlew @@ -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" "$@" diff --git a/smarketing-java/gradlew.bat b/smarketing-java/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/smarketing-java/gradlew.bat @@ -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 diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle new file mode 100644 index 0000000..188d7bd --- /dev/null +++ b/smarketing-java/marketing-content/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'org.postgresql:postgresql' +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java new file mode 100644 index 0000000..537a189 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -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); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java new file mode 100644 index 0000000..c196e58 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java @@ -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 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 contents = contentRepository.findByFilters(type, platformEnum, period, sortBy); + + return contents.stream() + .map(this::toContentResponse) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + @Override + public List getOngoingContents(String period) { + List 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(); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java new file mode 100644 index 0000000..4db4d8a --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java @@ -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 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); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java new file mode 100644 index 0000000..dd8e603 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -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 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); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java new file mode 100644 index 0000000..0712961 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java @@ -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 getContents(String contentType, String platform, String period, String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + List getOngoingContents(String period); + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + ContentDetailResponse getContentDetail(Long contentId); + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + */ + void deleteContent(Long contentId); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java new file mode 100644 index 0000000..6bf2960 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java @@ -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); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java new file mode 100644 index 0000000..d2c6751 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java @@ -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); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java new file mode 100644 index 0000000..3931d19 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java @@ -0,0 +1,9 @@ +package com.won.smarketing.content.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan(basePackages = "com.won.smarketing.content") +public class ContentConfig { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java new file mode 100644 index 0000000..f9a77b8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java @@ -0,0 +1,26 @@ + + +// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java +package com.won.smarketing.content.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ObjectMapper 설정 클래스 + * + * @author smarketing-team + * @version 1.0 + */ +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java new file mode 100644 index 0000000..9a19b77 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -0,0 +1,163 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +package com.won.smarketing.content.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 콘텐츠 도메인 모델 + * + * Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티 + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer에서 별도의 JPA 엔티티로 매핑 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Content { + + // ==================== 기본키 및 식별자 ==================== + private Long id; + + // ==================== 콘텐츠 분류 ==================== + private ContentType contentType; + private Platform platform; + + // ==================== 콘텐츠 내용 ==================== + private String title; + private String content; + + // ==================== 멀티미디어 및 메타데이터 ==================== + @Builder.Default + private List hashtags = new ArrayList<>(); + + @Builder.Default + private List images = new ArrayList<>(); + + // ==================== 상태 관리 ==================== + private ContentStatus status; + + // ==================== 생성 조건 ==================== + private CreationConditions creationConditions; + + // ==================== 매장 정보 ==================== + private Long storeId; + + // ==================== 프로모션 기간 ==================== + private LocalDateTime promotionStartDate; + private LocalDateTime promotionEndDate; + + // ==================== 메타데이터 ==================== + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 제목 수정 + * @param newTitle 새로운 제목 + */ + public void updateTitle(String newTitle) { + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); + } + this.title = newTitle.trim(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 내용 수정 + * @param newContent 새로운 내용 + */ + public void updateContent(String newContent) { + this.content = newContent; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 프로모션 기간 설정 + * @param startDate 시작일 + * @param endDate 종료일 + */ + public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다."); + } + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 상태 변경 + * @param newStatus 새로운 상태 + */ + public void updateStatus(ContentStatus newStatus) { + if (newStatus == null) { + throw new IllegalArgumentException("상태는 필수입니다."); + } + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 해시태그 추가 + * @param hashtag 추가할 해시태그 + */ + public void addHashtag(String hashtag) { + if (hashtag != null && !hashtag.trim().isEmpty()) { + if (this.hashtags == null) { + this.hashtags = new ArrayList<>(); + } + this.hashtags.add(hashtag.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 이미지 추가 + * @param imageUrl 추가할 이미지 URL + */ + public void addImage(String imageUrl) { + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + if (this.images == null) { + this.images = new ArrayList<>(); + } + this.images.add(imageUrl.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 프로모션 진행 중 여부 확인 + * @return 현재 시간이 프로모션 기간 내에 있으면 true + */ + public boolean isPromotionActive() { + if (promotionStartDate == null || promotionEndDate == null) { + return false; + } + LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate); + } + + /** + * 콘텐츠 게시 가능 여부 확인 + * @return 필수 정보가 모두 입력되어 있으면 true + */ + public boolean canBePublished() { + return title != null && !title.trim().isEmpty() + && contentType != null + && platform != null + && storeId != null; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java new file mode 100644 index 0000000..2f07e2c --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -0,0 +1,51 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +package com.won.smarketing.content.domain.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 ID 값 객체 + * Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리 + */ +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode +public class ContentId { + + private final Long value; + + /** + * Long 값으로부터 ContentId 생성 + * @param value ID 값 + * @return ContentId 인스턴스 + */ + public static ContentId of(Long value) { + if (value == null || value <= 0) { + throw new IllegalArgumentException("ContentId는 양수여야 합니다."); + } + return new ContentId(value); + } + + /** + * 새로운 ContentId 생성 (ID가 없는 경우) + * @return null 값을 가진 ContentId + */ + public static ContentId newId() { + return new ContentId(null); + } + + /** + * ID 값 존재 여부 확인 + * @return ID가 null이 아니면 true + */ + public boolean hasValue() { + return value != null; + } + + @Override + public String toString() { + return "ContentId{" + "value=" + value + '}'; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java new file mode 100644 index 0000000..b235310 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java @@ -0,0 +1,40 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 상태 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum ContentStatus { + + DRAFT("임시저장"), + PUBLISHED("게시됨"), + SCHEDULED("예약됨"), + DELETED("삭제됨"), + PROCESSING("처리중"); + + private final String displayName; + + /** + * 문자열로부터 ContentStatus 변환 + * @param value 문자열 값 + * @return ContentStatus enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static ContentStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다."); + } + + try { + return ContentStatus.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java new file mode 100644 index 0000000..f70228b --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java @@ -0,0 +1,39 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 타입 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum ContentType { + + SNS("SNS 게시물"), + POSTER("홍보 포스터"), + VIDEO("동영상"), + BLOG("블로그 포스트"); + + private final String displayName; + + /** + * 문자열로부터 ContentType 변환 + * @param value 문자열 값 + * @return ContentType enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static ContentType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다."); + } + + try { + return ContentType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java new file mode 100644 index 0000000..d7a9543 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -0,0 +1,58 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +package com.won.smarketing.content.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 도메인 모델 + * Clean Architecture의 Domain Layer에 위치하는 값 객체 + * + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer의 JPA 엔티티는 별도로 관리 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreationConditions { + + private String id; + private String category; + private String requirement; + private String toneAndManner; + private String emotionIntensity; + private String eventName; + private LocalDate startDate; + private LocalDate endDate; + private String photoStyle; + private String promotionType; + + public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + } + + /** + * 이벤트 기간 유효성 검증 + * @return 시작일이 종료일보다 이전이거나 같으면 true + */ + public boolean isValidEventPeriod() { + if (startDate == null || endDate == null) { + return true; + } + return !startDate.isAfter(endDate); + } + + /** + * 이벤트 조건 유무 확인 + * @return 이벤트명이나 날짜가 설정되어 있으면 true + */ + public boolean hasEventInfo() { + return eventName != null && !eventName.trim().isEmpty() + || startDate != null + || endDate != null; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java new file mode 100644 index 0000000..66e266c --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java @@ -0,0 +1,41 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 플랫폼 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum Platform { + + INSTAGRAM("인스타그램"), + NAVER_BLOG("네이버 블로그"), + FACEBOOK("페이스북"), + KAKAO_STORY("카카오스토리"), + YOUTUBE("유튜브"), + GENERAL("일반"); + + private final String displayName; + + /** + * 문자열로부터 Platform 변환 + * @param value 문자열 값 + * @return Platform enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static Platform fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Platform 값은 null일 수 없습니다."); + } + + try { + return Platform.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java new file mode 100644 index 0000000..a2bfc43 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -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 findById(ContentId id); + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + List findByFilters(ContentType contentType, Platform platform, String period, String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + List findOngoingContents(String period); + + /** + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID + */ + void deleteById(ContentId id); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..d3a6e42 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java @@ -0,0 +1,38 @@ +package com.won.smarketing.content.domain.repository; +import com.won.smarketing.content.infrastructure.entity.ContentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA ContentRepository + * JPA 기반 콘텐츠 데이터 접근 + */ +@Repository +public interface SpringDataContentRepository extends JpaRepository { + + /** + * 매장별 콘텐츠 조회 + * + * @param storeId 매장 ID + * @return 콘텐츠 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입별 조회 + * + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼별 조회 + * + * @param platform 플랫폼 + * @return 콘텐츠 목록 + */ + List findByPlatform(String platform); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java new file mode 100644 index 0000000..677853a --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java @@ -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 generateHashtags(String content, Platform platform); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java new file mode 100644 index 0000000..6c0f1cb --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java @@ -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 generatePosterSizes(String baseImage); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java new file mode 100644 index 0000000..b549b05 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -0,0 +1,84 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 JPA 엔티티 + * Infrastructure Layer에서 데이터베이스 매핑을 담당 + */ +@Entity +@Table(name = "content_conditions") +@Getter +@Setter +public class ContentConditionsJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + private ContentJpaEntity content; + + @Column(name = "category", length = 100) + private String category; + + @Column(name = "requirement", columnDefinition = "TEXT") + private String requirement; + + @Column(name = "tone_and_manner", length = 100) + private String toneAndManner; + + @Column(name = "emotion_intensity", length = 50) + private String emotionIntensity; + + @Column(name = "event_name", length = 200) + private String eventName; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "photo_style", length = 100) + private String photoStyle; + + @Column(name = "promotion_type", length = 100) + private String promotionType; + + // 생성자 + public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + this.content = content; + this.category = category; + this.requirement = requirement; + this.toneAndManner = toneAndManner; + this.emotionIntensity = emotionIntensity; + this.eventName = eventName; + this.startDate = startDate; + this.endDate = endDate; + this.photoStyle = photoStyle; + this.promotionType = promotionType; + } + + public ContentConditionsJpaEntity() { + + } + + // 팩토리 메서드 + public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity, + eventName, startDate, endDate, photoStyle, promotionType); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java new file mode 100644 index 0000000..ba941d4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java @@ -0,0 +1,60 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 엔티티 + * 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Data +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ContentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "platform", nullable = false) + private String platform; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false) + private String status; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java new file mode 100644 index 0000000..bcc8499 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -0,0 +1,70 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 콘텐츠 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +public class ContentJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "content_type", nullable = false, length = 50) + private String contentType; + + @Column(name = "platform", length = 50) + private String platform; + + @Column(name = "title", length = 500) + private String title; + + @Column(name = "PromotionStartDate") + private LocalDateTime PromotionStartDate; + + @Column(name = "PromotionEndDate") + private LocalDateTime PromotionEndDate; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags", columnDefinition = "TEXT") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리 + @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private ContentConditionsJpaEntity conditions; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java new file mode 100644 index 0000000..b1d0e6d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java @@ -0,0 +1,32 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.List; + +/** + * AI 콘텐츠 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * @param title 제목 + * @param category 카테고리 + * @param platform 플랫폼 + * @param conditions 생성 조건 + * @return 생성된 콘텐츠 텍스트 + */ + String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions); + + /** + * 해시태그 생성 + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 생성된 해시태그 목록 + */ + List generateHashtags(String content, Platform platform); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java new file mode 100644 index 0000000..8bbe931 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java @@ -0,0 +1,29 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.Map; + +/** + * AI 포스터 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiPosterGenerator { + + /** + * 포스터 이미지 생성 + * @param title 제목 + * @param category 카테고리 + * @param conditions 생성 조건 + * @return 생성된 포스터 이미지 URL + */ + String generatePoster(String title, String category, CreationConditions conditions); + + /** + * 포스터 다양한 사이즈 생성 + * @param originalImage 원본 이미지 URL + * @return 사이즈별 이미지 URL 맵 + */ + Map generatePosterSizes(String originalImage); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java new file mode 100644 index 0000000..9d72f1f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -0,0 +1,95 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +// 수정: domain 패키지의 인터페이스를 import +import com.won.smarketing.content.domain.service.AiContentGenerator; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * Claude AI를 활용한 콘텐츠 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiContentGenerator implements AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + */ + @Override + public String generateSnsContent(SnsContentCreateRequest request) { + try { + String prompt = buildContentPrompt(request); + return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform())); + } catch (Exception e) { + log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); + return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); + } + } + + /** + * 플랫폼별 해시태그 생성 + */ + @Override + public List generateHashtags(String content, Platform platform) { + try { + return generateDummyHashtags(platform); + } catch (Exception e) { + log.error("해시태그 생성 실패: {}", e.getMessage(), e); + return generateFallbackHashtags(); + } + } + + private String buildContentPrompt(SnsContentCreateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + prompt.append("플랫폼: ").append(request.getPlatform()).append("\n"); + + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); + } + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + private String generateDummySnsContent(String title, Platform platform) { + String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" + + "저희 매장에서 특별한 경험을 만나보세요.\n" + + "고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n"; + + if (platform == Platform.INSTAGRAM) { + return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸"; + } else { + return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨"; + } + } + + private String generateFallbackContent(String title, Platform platform) { + return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!"; + } + + private List generateDummyHashtags(Platform platform) { + if (platform == Platform.INSTAGRAM) { + return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램"); + } else { + return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원"); + } + } + + private List generateFallbackHashtags() { + return Arrays.asList("#소상공인", "#마케팅", "#홍보"); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java new file mode 100644 index 0000000..7495966 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -0,0 +1,86 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Claude AI를 활용한 포스터 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiPosterGenerator implements AiPosterGenerator { + + /** + * 포스터 생성 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + @Override + public String generatePoster(PosterContentCreateRequest request) { + try { + // Claude AI API 호출 로직 + String prompt = buildPosterPrompt(request); + + // TODO: 실제 Claude AI API 호출 + // 현재는 더미 데이터 반환 + return generateDummyPosterUrl(request.getTitle()); + + } catch (Exception e) { + log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); + return generateFallbackPosterUrl(); + } + } + + /** + * 다양한 사이즈의 포스터 생성 + * + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 + */ + @Override + public Map generatePosterSizes(String baseImage) { + Map sizes = new HashMap<>(); + + // 다양한 사이즈 생성 (더미 구현) + sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); + sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); + sizes.put("facebook_post", baseImage + "_1200x630.jpg"); + sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); + + return sizes; + } + + private String buildPosterPrompt(PosterContentCreateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); + } + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + private String generateDummyPosterUrl(String title) { + return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; + } + + private String generateFallbackPosterUrl() { + return "https://dummy-ai-service.com/posters/fallback.jpg"; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java new file mode 100644 index 0000000..44fdb68 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -0,0 +1,213 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +package com.won.smarketing.content.infrastructure.mapper; + +import com.won.smarketing.content.domain.model.*; +import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 콘텐츠 도메인-엔티티 매퍼 + * Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당 + * + * @author smarketing-team + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentMapper { + + private final ObjectMapper objectMapper; + + /** + * 도메인 모델을 JPA 엔티티로 변환 + * + * @param content 도메인 콘텐츠 + * @return JPA 엔티티 + */ + public ContentJpaEntity toEntity(Content content) { + if (content == null) { + return null; + } + + ContentJpaEntity entity = new ContentJpaEntity(); + + // 기본 필드 매핑 + if (content.getId() != null) { + entity.setId(content.getId()); + } + entity.setStoreId(content.getStoreId()); + entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null); + entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null); + entity.setTitle(content.getTitle()); + entity.setContent(content.getContent()); + entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT"); + entity.setPromotionStartDate(content.getPromotionStartDate()); + entity.setPromotionEndDate(content.getPromotionEndDate()); + entity.setCreatedAt(content.getCreatedAt()); + entity.setUpdatedAt(content.getUpdatedAt()); + + // 컬렉션 필드를 JSON으로 변환 + entity.setHashtags(convertListToJson(content.getHashtags())); + entity.setImages(convertListToJson(content.getImages())); + + // 생성 조건 정보 매핑 + if (content.getCreationConditions() != null) { + ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions()); + conditionsEntity.setContent(entity); + entity.setConditions(conditionsEntity); + } + + return entity; + } + + /** + * JPA 엔티티를 도메인 모델로 변환 + * + * @param entity JPA 엔티티 + * @return 도메인 모델 + */ + public Content toDomain(ContentJpaEntity entity) { + if (entity == null) { + return null; + } + + return Content.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .contentType(parseContentType(entity.getContentType())) + .platform(parsePlatform(entity.getPlatform())) + .title(entity.getTitle()) + .content(entity.getContent()) + .hashtags(convertJsonToList(entity.getHashtags())) + .images(convertJsonToList(entity.getImages())) + .status(parseContentStatus(entity.getStatus())) + .promotionStartDate(entity.getPromotionStartDate()) + .promotionEndDate(entity.getPromotionEndDate()) + .creationConditions(mapToConditionsDomain(entity.getConditions())) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * CreationConditions 도메인을 JPA 엔티티로 변환 + */ + private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) { + ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity(); + entity.setCategory(conditions.getCategory()); + entity.setRequirement(conditions.getRequirement()); + entity.setToneAndManner(conditions.getToneAndManner()); + entity.setEmotionIntensity(conditions.getEmotionIntensity()); + entity.setEventName(conditions.getEventName()); + entity.setStartDate(conditions.getStartDate()); + entity.setEndDate(conditions.getEndDate()); + entity.setPhotoStyle(conditions.getPhotoStyle()); + entity.setPromotionType(conditions.getPromotionType()); + return entity; + } + + /** + * CreationConditions JPA 엔티티를 도메인으로 변환 + */ + private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) { + if (entity == null) { + return null; + } + + return CreationConditions.builder() + .category(entity.getCategory()) + .requirement(entity.getRequirement()) + .toneAndManner(entity.getToneAndManner()) + .emotionIntensity(entity.getEmotionIntensity()) + .eventName(entity.getEventName()) + .startDate(entity.getStartDate()) + .endDate(entity.getEndDate()) + .photoStyle(entity.getPhotoStyle()) + .promotionType(entity.getPromotionType()) + .build(); + } + + /** + * List를 JSON 문자열로 변환 + */ + private String convertListToJson(List list) { + if (list == null || list.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + log.warn("Failed to convert list to JSON: {}", e.getMessage()); + return null; + } + } + + /** + * JSON 문자열을 List로 변환 + */ + private List convertJsonToList(String json) { + if (json == null || json.trim().isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to convert JSON to list: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * 문자열을 ContentType 열거형으로 변환 + */ + private ContentType parseContentType(String contentType) { + if (contentType == null) { + return null; + } + try { + return ContentType.valueOf(contentType); + } catch (IllegalArgumentException e) { + log.warn("Unknown content type: {}", contentType); + return null; + } + } + + /** + * 문자열을 Platform 열거형으로 변환 + */ + private Platform parsePlatform(String platform) { + if (platform == null) { + return null; + } + try { + return Platform.valueOf(platform); + } catch (IllegalArgumentException e) { + log.warn("Unknown platform: {}", platform); + return null; + } + } + + /** + * 문자열을 ContentStatus 열거형으로 변환 + */ + private ContentStatus parseContentStatus(String status) { + if (status == null) { + return ContentStatus.DRAFT; + } + try { + return ContentStatus.valueOf(status); + } catch (IllegalArgumentException e) { + log.warn("Unknown content status: {}", status); + return ContentStatus.DRAFT; + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java new file mode 100644 index 0000000..f3f38ed --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -0,0 +1,147 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.won.smarketing.content.infrastructure.mapper.ContentMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * JPA를 활용한 콘텐츠 리포지토리 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용 + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class JpaContentRepository implements ContentRepository { + + private final JpaContentRepositoryInterface jpaRepository; + private final ContentMapper contentMapper; + + /** + * 콘텐츠 저장 + * @param content 저장할 도메인 콘텐츠 + * @return 저장된 도메인 콘텐츠 + */ + @Override + public Content save(Content content) { + log.debug("Saving content: {}", content.getTitle()); + + // 도메인 모델을 JPA 엔티티로 변환 + ContentJpaEntity entity = contentMapper.toEntity(content); + + // JPA로 저장 + ContentJpaEntity savedEntity = jpaRepository.save(entity); + + // JPA 엔티티를 도메인 모델로 변환하여 반환 + Content savedContent = contentMapper.toDomain(savedEntity); + + log.debug("Content saved with ID: {}", savedContent.getId()); + return savedContent; + } + + /** + * ID로 콘텐츠 조회 + * @param id 콘텐츠 ID + * @return 조회된 도메인 콘텐츠 + */ + @Override + public Optional findById(ContentId id) { + log.debug("Finding content by ID: {}", id.getValue()); + + return jpaRepository.findById(id.getValue()) + .map(contentMapper::toDomain); + } + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 (현재는 사용하지 않음) + * @param sortBy 정렬 기준 (현재는 사용하지 않음) + * @return 도메인 콘텐츠 목록 + */ + @Override + public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { + log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform); + + String contentTypeStr = contentType != null ? contentType.name() : null; + String platformStr = platform != null ? platform.name() : null; + + List entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * @param period 기간 (현재는 사용하지 않음) + * @return 진행 중인 도메인 콘텐츠 목록 + */ + @Override + public List findOngoingContents(String period) { + log.debug("Finding ongoing contents"); + + List entities = jpaRepository.findOngoingContents(); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID + */ + @Override + public void deleteById(ContentId id) { + log.debug("Deleting content by ID: {}", id.getValue()); + + jpaRepository.deleteById(id.getValue()); + + log.debug("Content deleted successfully"); + } + + /** + * 매장 ID로 콘텐츠 목록 조회 (추가 메서드) + * @param storeId 매장 ID + * @return 도메인 콘텐츠 목록 + */ + public List findByStoreId(Long storeId) { + log.debug("Finding contents by store ID: {}", storeId); + + List entities = jpaRepository.findByStoreId(storeId); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 타입으로 조회 (추가 메서드) + * @param contentType 콘텐츠 타입 + * @return 도메인 콘텐츠 목록 + */ + public List findByContentType(ContentType contentType) { + log.debug("Finding contents by type: {}", contentType); + + List entities = jpaRepository.findByContentType(contentType.name()); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java new file mode 100644 index 0000000..37c4e74 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -0,0 +1,87 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * Spring Data JPA 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근 + */ +public interface JpaContentRepositoryInterface extends JpaRepository { + + /** + * 매장 ID로 콘텐츠 목록 조회 + * @param storeId 매장 ID + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입으로 조회 + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼으로 조회 + * @param platform 플랫폼 + * @return 콘텐츠 엔티티 목록 + */ + List findByPlatform(String platform); + + /** + * 상태로 조회 + * @param status 상태 + * @return 콘텐츠 엔티티 목록 + */ + List findByStatus(String status); + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 (null 가능) + * @param platform 플랫폼 (null 가능) + * @param status 상태 (null 가능) + * @return 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "(:contentType IS NULL OR c.contentType = :contentType) AND " + + "(:platform IS NULL OR c.platform = :platform) AND " + + "(:status IS NULL OR c.status = :status) " + + "ORDER BY c.createdAt DESC") + List findByFilters(@Param("contentType") String contentType, + @Param("platform") String platform, + @Param("status") String status); + + /** + * 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠) + * @return 진행 중인 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "c.status IN ('PUBLISHED', 'SCHEDULED') " + + "ORDER BY c.createdAt DESC") + List findOngoingContents(); + + /** + * 매장 ID와 콘텐츠 타입으로 조회 + * @param storeId 매장 ID + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreIdAndContentType(Long storeId, String contentType); + + /** + * 최근 생성된 콘텐츠 조회 (limit 적용) + * @param storeId 매장 ID + * @return 최근 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " + + "ORDER BY c.createdAt DESC") + List findRecentContentsByStoreId(@Param("storeId") Long storeId); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java new file mode 100644 index 0000000..4feb6b7 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java @@ -0,0 +1,169 @@ +package com.won.smarketing.content.presentation.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.content.application.usecase.ContentQueryUseCase; +import com.won.smarketing.content.application.usecase.PosterContentUseCase; +import com.won.smarketing.content.application.usecase.SnsContentUseCase; +import com.won.smarketing.content.presentation.dto.*; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +/** + * 마케팅 콘텐츠 관리를 위한 REST API 컨트롤러 + * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 + */ +@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") +@RestController +@RequestMapping("/api/content") +@RequiredArgsConstructor +public class ContentController { + + private final SnsContentUseCase snsContentUseCase; + private final PosterContentUseCase posterContentUseCase; + private final ContentQueryUseCase contentQueryUseCase; + + /** + * SNS 게시물 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 + */ + @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") + @PostMapping("/sns/generate") + public ResponseEntity> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) { + SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request); + return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); + } + + /** + * SNS 게시물 저장 + * + * @param request SNS 콘텐츠 저장 요청 + * @return 저장 성공 응답 + */ + @Operation(summary = "SNS 게시물 저장", description = "생성된 SNS 게시물을 저장합니다.") + @PostMapping("/sns/save") + public ResponseEntity> saveSnsContent(@Valid @RequestBody SnsContentSaveRequest request) { + snsContentUseCase.saveSnsContent(request); + return ResponseEntity.ok(ApiResponse.success(null, "SNS 콘텐츠가 성공적으로 저장되었습니다.")); + } + + /** + * 홍보 포스터 생성 + * + * @param request 포스터 콘텐츠 생성 요청 + * @return 생성된 포스터 콘텐츠 정보 + */ + @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") + @PostMapping("/poster/generate") + public ResponseEntity> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { + PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); + return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); + } + + /** + * 홍보 포스터 저장 + * + * @param request 포스터 콘텐츠 저장 요청 + * @return 저장 성공 응답 + */ + @Operation(summary = "홍보 포스터 저장", description = "생성된 홍보 포스터를 저장합니다.") + @PostMapping("/poster/save") + public ResponseEntity> savePosterContent(@Valid @RequestBody PosterContentSaveRequest request) { + posterContentUseCase.savePosterContent(request); + return ResponseEntity.ok(ApiResponse.success(null, "포스터 콘텐츠가 성공적으로 저장되었습니다.")); + } + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + @Operation(summary = "콘텐츠 수정", description = "기존 콘텐츠를 수정합니다.") + @PutMapping("/{contentId}") + public ResponseEntity> updateContent( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId, + @Valid @RequestBody ContentUpdateRequest request) { + ContentUpdateResponse response = contentQueryUseCase.updateContent(contentId, request); + return ResponseEntity.ok(ApiResponse.success(response, "콘텐츠가 성공적으로 수정되었습니다.")); + } + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 필터 + * @param platform 플랫폼 필터 + * @param period 기간 필터 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Operation(summary = "콘텐츠 목록 조회", description = "다양한 필터와 정렬 옵션으로 콘텐츠 목록을 조회합니다.") + @GetMapping + public ResponseEntity>> getContents( + @Parameter(description = "콘텐츠 타입") + @RequestParam(required = false) String contentType, + @Parameter(description = "플랫폼") + @RequestParam(required = false) String platform, + @Parameter(description = "기간") + @RequestParam(required = false) String period, + @Parameter(description = "정렬 기준") + @RequestParam(required = false) String sortBy) { + List response = contentQueryUseCase.getContents(contentType, platform, period, sortBy); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 필터 + * @return 진행 중인 콘텐츠 목록 + */ + @Operation(summary = "진행 콘텐츠 조회", description = "현재 진행 중인 콘텐츠 목록을 조회합니다.") + @GetMapping("/ongoing") + public ResponseEntity>> getOngoingContents( + @Parameter(description = "기간") + @RequestParam(required = false) String period) { + List response = contentQueryUseCase.getOngoingContents(period); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 조회할 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + @Operation(summary = "콘텐츠 상세 조회", description = "특정 콘텐츠의 상세 정보를 조회합니다.") + @GetMapping("/{contentId}") + public ResponseEntity> getContentDetail( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId) { + ContentDetailResponse response = contentQueryUseCase.getContentDetail(contentId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + * @return 삭제 성공 응답 + */ + @Operation(summary = "콘텐츠 삭제", description = "콘텐츠를 삭제합니다.") + @DeleteMapping("/{contentId}") + public ResponseEntity> deleteContent( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId) { + contentQueryUseCase.deleteContent(contentId); + return ResponseEntity.ok(ApiResponse.success(null, "콘텐츠가 성공적으로 삭제되었습니다.")); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java new file mode 100644 index 0000000..7cc6a52 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java @@ -0,0 +1,86 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 콘텐츠 상세 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 상세 응답") +public class ContentDetailResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "생성 조건") + private CreationConditionsDto creationConditions; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; + + /** + * 생성 조건 내부 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class CreationConditionsDto { + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java new file mode 100644 index 0000000..8a35e35 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java @@ -0,0 +1,37 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 목록 조회 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 목록 조회 요청") +public class ContentListRequest { + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "조회 기간", example = "7days") + private String period; + + @Schema(description = "정렬 기준", example = "createdAt") + private String sortBy; + + @Schema(description = "정렬 방향", example = "DESC") + private String sortDirection; + + @Schema(description = "페이지 번호", example = "0") + private Integer page; + + @Schema(description = "페이지 크기", example = "20") + private Integer size; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java new file mode 100644 index 0000000..47060a0 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 재생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 재생성 요청") +public class ContentRegenerateRequest { + + @Schema(description = "원본 콘텐츠 ID", example = "1", required = true) + @NotNull(message = "원본 콘텐츠 ID는 필수입니다") + private Long originalContentId; + + @Schema(description = "수정된 톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "수정된 프로모션 유형", example = "신메뉴 알림") + private String promotionType; + + @Schema(description = "수정된 감정 강도", example = "열정적") + private String emotionIntensity; + + @Schema(description = "추가 요구사항", example = "더 감성적으로 작성해주세요") + private String additionalRequirements; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java new file mode 100644 index 0000000..964f4a2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java @@ -0,0 +1,364 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 콘텐츠 응답 DTO + * 콘텐츠 목록 조회 시 사용되는 기본 응답 DTO + * + * 이 클래스는 콘텐츠의 핵심 정보만을 포함하여 목록 조회 시 성능을 최적화합니다. + * 상세 정보가 필요한 경우 ContentDetailResponse를 사용합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 응답") +public class ContentResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST", + allowableValues = {"SNS_POST", "POSTER"}) + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== 콘텐츠 정보 ==================== + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용", example = "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!") + private String content; + + @Schema(description = "해시태그 목록", example = "[\"#맛집\", \"#신메뉴\", \"#추천\", \"#인스타그램\"]") + private List hashtags; + + @Schema(description = "이미지 URL 목록", + example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]") + private List images; + + // ==================== 상태 관리 ==================== + + @Schema(description = "상태", example = "PUBLISHED", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED", "ARCHIVED"}) + private String status; + + @Schema(description = "상태 표시명", example = "발행완료") + private String statusDisplay; + + // ==================== 홍보 기간 ==================== + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + + // ==================== 시간 정보 ==================== + + @Schema(description = "생성일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T14:20:00") + private LocalDateTime updatedAt; + + // ==================== 계산된 필드들 ==================== + + @Schema(description = "홍보 진행 상태", example = "ONGOING", + allowableValues = {"UPCOMING", "ONGOING", "COMPLETED"}) + private String promotionStatus; + + @Schema(description = "남은 홍보 일수", example = "5") + private Long remainingDays; + + @Schema(description = "홍보 진행률 (%)", example = "60.5") + private Double progressPercentage; + + @Schema(description = "콘텐츠 요약 (첫 50자)", example = "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...") + private String contentSummary; + + @Schema(description = "이미지 개수", example = "3") + private Integer imageCount; + + @Schema(description = "해시태그 개수", example = "8") + private Integer hashtagCount; + + @Schema(description = "조회수", example = "8") + private Integer viewCount; + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 요약 생성 + * 콘텐츠가 길 경우 첫 50자만 표시하고 "..." 추가 + * + * @param content 원본 콘텐츠 + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public static String createContentSummary(String content, int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 홍보 상태 계산 + * 현재 시간과 홍보 기간을 비교하여 상태 결정 + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 홍보 상태 + */ + public static String calculatePromotionStatus(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return "UNKNOWN"; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return "UPCOMING"; // 홍보 예정 + } else if (now.isAfter(endDate)) { + return "COMPLETED"; // 홍보 완료 + } else { + return "ONGOING"; // 홍보 진행중 + } + } + + /** + * 남은 일수 계산 + * 홍보 종료일까지 남은 일수 계산 + * + * @param endDate 홍보 종료일 + * @return 남은 일수 (음수면 0 반환) + */ + public static Long calculateRemainingDays(LocalDateTime endDate) { + if (endDate == null) { + return 0L; + } + + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(endDate)) { + return 0L; + } + + return java.time.Duration.between(now, endDate).toDays(); + } + + /** + * 진행률 계산 + * 홍보 기간 대비 진행률 계산 (0-100%) + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 진행률 (0-100%) + */ + public static Double calculateProgressPercentage(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return 0.0; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return 0.0; // 아직 시작 안함 + } else if (now.isAfter(endDate)) { + return 100.0; // 완료 + } + + long totalDuration = java.time.Duration.between(startDate, endDate).toHours(); + long elapsedDuration = java.time.Duration.between(startDate, now).toHours(); + + if (totalDuration == 0) { + return 100.0; + } + + return (double) elapsedDuration / totalDuration * 100.0; + } + + /** + * 상태 표시명 변환 + * 영문 상태를 한글로 변환 + * + * @param status 영문 상태 + * @return 한글 상태명 + */ + public static String getStatusDisplay(String status) { + if (status == null) { + return "알 수 없음"; + } + + switch (status) { + case "DRAFT": + return "임시저장"; + case "PUBLISHED": + return "발행완료"; + case "SCHEDULED": + return "예약발행"; + case "ARCHIVED": + return "보관됨"; + default: + return status; + } + } + + // ==================== Builder 확장 메서드 ==================== + + /** + * 도메인 엔티티에서 ContentResponse 생성 + * 계산된 필드들을 자동으로 설정 + * + * @param content 콘텐츠 도메인 엔티티 + * @return ContentResponse + */ + public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) { + ContentResponseBuilder builder = ContentResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .statusDisplay(getStatusDisplay(content.getStatus().name())) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .createdAt(content.getCreatedAt()) + .updatedAt(content.getUpdatedAt()); + + // 계산된 필드들 설정 + builder.contentSummary(createContentSummary(content.getContent(), 50)); + builder.imageCount(content.getImages() != null ? content.getImages().size() : 0); + builder.hashtagCount(content.getHashtags() != null ? content.getHashtags().size() : 0); + + // 홍보 관련 계산 필드들 + builder.promotionStatus(calculatePromotionStatus( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + builder.remainingDays(calculateRemainingDays(content.getPromotionEndDate())); + builder.progressPercentage(calculateProgressPercentage( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + + return builder.build(); + } + + // ==================== 유틸리티 메서드 ==================== + + /** + * 콘텐츠가 현재 활성 상태인지 확인 + * + * @return 홍보 기간 내이고 발행 상태면 true + */ + public boolean isActive() { + return "PUBLISHED".equals(status) && "ONGOING".equals(promotionStatus); + } + + /** + * 콘텐츠 수정 가능 여부 확인 + * + * @return 임시저장 상태이거나 예약발행 상태면 true + */ + public boolean isEditable() { + return "DRAFT".equals(status) || "SCHEDULED".equals(status); + } + + /** + * 이미지가 있는 콘텐츠인지 확인 + * + * @return 이미지가 1개 이상 있으면 true + */ + public boolean hasImages() { + return images != null && !images.isEmpty(); + } + + /** + * 해시태그가 있는 콘텐츠인지 확인 + * + * @return 해시태그가 1개 이상 있으면 true + */ + public boolean hasHashtags() { + return hashtags != null && !hashtags.isEmpty(); + } + + /** + * 디버깅용 toString (간소화된 정보만) + */ + @Override + public String toString() { + return "ContentResponse{" + + "contentId=" + contentId + + ", contentType='" + contentType + '\'' + + ", platform='" + platform + '\'' + + ", title='" + title + '\'' + + ", status='" + status + '\'' + + ", promotionStatus='" + promotionStatus + '\'' + + ", createdAt=" + createdAt + + '}'; + } +} + +/* +==================== 사용 예시 ==================== + +// 1. 도메인 엔티티에서 DTO 생성 +Content domainContent = contentRepository.findById(contentId); +ContentResponse response = ContentResponse.fromDomain(domainContent); + +// 2. 수동으로 빌더 사용 +ContentResponse response = ContentResponse.builder() + .contentId(1L) + .contentType("SNS_POST") + .platform("INSTAGRAM") + .title("맛있는 신메뉴") + .content("특별한 신메뉴가 출시되었습니다!") + .status("PUBLISHED") + .build(); + +// 3. 비즈니스 로직 활용 +boolean canEdit = response.isEditable(); +boolean isLive = response.isActive(); +String summary = response.getContentSummary(); + +==================== JSON 응답 예시 ==================== + +{ + "contentId": 1, + "contentType": "SNS_POST", + "platform": "INSTAGRAM", + "title": "맛있는 신메뉴를 소개합니다!", + "content": "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!", + "hashtags": ["#맛집", "#신메뉴", "#추천", "#인스타그램"], + "images": ["https://example.com/image1.jpg"], + "status": "PUBLISHED", + "statusDisplay": "발행완료", + "promotionStartDate": "2024-01-15T09:00:00", + "promotionEndDate": "2024-01-22T23:59:59", + "createdAt": "2024-01-15T10:30:00", + "updatedAt": "2024-01-15T14:20:00", + "promotionStatus": "ONGOING", + "remainingDays": 5, + "progressPercentage": 60.5, + "contentSummary": "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...", + "imageCount": 1, + "hashtagCount": 4 +} +*/ \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java new file mode 100644 index 0000000..fed7dfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 콘텐츠 통계 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 통계 응답") +public class ContentStatisticsResponse { + + @Schema(description = "총 콘텐츠 수", example = "150") + private Long totalContents; + + @Schema(description = "이번 달 생성된 콘텐츠 수", example = "25") + private Long thisMonthContents; + + @Schema(description = "발행된 콘텐츠 수", example = "120") + private Long publishedContents; + + @Schema(description = "임시저장된 콘텐츠 수", example = "30") + private Long draftContents; + + @Schema(description = "콘텐츠 타입별 통계") + private Map contentTypeStats; + + @Schema(description = "플랫폼별 통계") + private Map platformStats; + + @Schema(description = "월별 생성 통계 (최근 6개월)") + private Map monthlyStats; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java new file mode 100644 index 0000000..d550f0f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 수정 요청") +public class ContentUpdateRequest { + + @Schema(description = "제목", example = "수정된 제목") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java new file mode 100644 index 0000000..3296ee2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java @@ -0,0 +1,35 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 수정 응답") +public class ContentUpdateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "수정된 제목", example = "수정된 제목") + private String title; + + @Schema(description = "수정된 콘텐츠 내용") + private String content; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java new file mode 100644 index 0000000..403cdfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java @@ -0,0 +1,45 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 생성 조건") +public class CreationConditionsDto { + + @Schema(description = "카테고리", example = "음식") + private String category; + + @Schema(description = "생성 요구사항", example = "젊은 고객층을 타겟으로 한 재미있는 콘텐츠") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근하고 활발한") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "시작일") + private LocalDate startDate; + + @Schema(description = "종료일") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "모던하고 깔끔한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java new file mode 100644 index 0000000..047cb2d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java @@ -0,0 +1,47 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 진행 중인 콘텐츠 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "진행 중인 콘텐츠 응답") +public class OngoingContentResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "진행 중인 이벤트") + private String title; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "남은 일수", example = "5") + private Long remainingDays; + + @Schema(description = "진행률 (%)", example = "60.5") + private Double progressPercentage; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java new file mode 100644 index 0000000..3ea3a15 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java @@ -0,0 +1,79 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 포스터 콘텐츠 생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 생성 요청") +public class PosterContentCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "홍보 대상", example = "메뉴", required = true) + @NotBlank(message = "홍보 대상은 필수입니다") + private String targetAudience; + + @Schema(description = "홍보 시작일", required = true) + @NotNull(message = "홍보 시작일은 필수입니다") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", required = true) + @NotNull(message = "홍보 종료일은 필수입니다") + private LocalDateTime promotionEndDate; + + @Schema(description = "이벤트명 (이벤트 홍보시)", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "업로드된 이미지 URL 목록", required = true) + @NotNull(message = "이미지는 1개 이상 필수입니다") + @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + private List images; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java new file mode 100644 index 0000000..0c02b68 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java @@ -0,0 +1,49 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 포스터 콘텐츠 생성 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "포스터 콘텐츠 생성 응답") +public class PosterContentCreateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "생성된 포스터 제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "생성된 포스터 텍스트 내용") + private String content; + + @Schema(description = "생성된 포스터 타입") + private String contentType; + + @Schema(description = "포스터 이미지 URL") + private String posterImage; + + @Schema(description = "원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "생성 상태", example = "DRAFT") + private String status; + + @Schema(description = "포스터사이즈", example = "800x600") + private Map posterSizes; + +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java new file mode 100644 index 0000000..5335d11 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java @@ -0,0 +1,66 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** + * 포스터 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 저장 요청") +public class PosterContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "선택된 포스터 이미지 URL") + private List images; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java new file mode 100644 index 0000000..70235b5 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java @@ -0,0 +1,160 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** + * SNS 콘텐츠 생성 요청 DTO + * + * AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다. + * 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 요청") +public class SnsContentCreateRequest { + + // ==================== 기본 정보 ==================== + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "대상 플랫폼", + example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}, + required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "콘텐츠 제목", example = "1", required = true) + @NotNull(message = "콘텐츠 제목은 필수입니다") + private String title; + + // ==================== 콘텐츠 생성 조건 ==================== + + @Schema(description = "콘텐츠 카테고리", + example = "메뉴소개", + allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"}) + private String category; + + @Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용", + example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + @Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요") + private String requirement; + + @Schema(description = "톤앤매너", + example = "친근함", + allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) + private String toneAndManner; + + @Schema(description = "감정 강도", + example = "보통", + allowableValues = {"약함", "보통", "강함"}) + private String emotionIntensity; + + // ==================== 이벤트 정보 ==================== + + @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", + example = "신메뉴 출시 이벤트") + @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") + private String eventName; + + @Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)", + example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)", + example = "2024-01-31") + private LocalDate endDate; + + // ==================== 미디어 정보 ==================== + + @Schema(description = "업로드된 이미지 파일 경로 목록") + private List images; + + @Schema(description = "사진 스타일 선호도", + example = "밝고 화사한", + allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"}) + private String photoStyle; + + // ==================== 추가 옵션 ==================== + + @Schema(description = "해시태그 포함 여부", example = "true") + @Builder.Default + private Boolean includeHashtags = true; + + @Schema(description = "이모지 포함 여부", example = "true") + @Builder.Default + private Boolean includeEmojis = true; + + @Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true") + @Builder.Default + private Boolean includeCallToAction = true; + + @Schema(description = "매장 위치 정보 포함 여부", example = "false") + @Builder.Default + private Boolean includeLocation = false; + + // ==================== 플랫폼별 옵션 ==================== + + @Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false") + @Builder.Default + private Boolean forInstagramStory = false; + + @Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false") + @Builder.Default + private Boolean forNaverBlogPost = false; + + // ==================== AI 생성 옵션 ==================== + + @Schema(description = "대안 제목 생성 개수", example = "3") + @Builder.Default + private Integer alternativeTitleCount = 3; + + @Schema(description = "대안 해시태그 세트 생성 개수", example = "2") + @Builder.Default + private Integer alternativeHashtagSetCount = 2; + + @Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo") + private String preferredAiModel; + + // ==================== 검증 메서드 ==================== + + /** + * 이벤트 날짜 유효성 검증 + * 시작일이 종료일보다 이후인지 확인 + */ + public boolean isValidEventDates() { + if (startDate != null && endDate != null) { + return !startDate.isAfter(endDate); + } + return true; + } + + /** + * 플랫폼별 필수 조건 검증 + */ + public boolean isValidForPlatform() { + if ("INSTAGRAM".equals(platform)) { + // 인스타그램은 이미지가 권장됨 + return images != null && !images.isEmpty(); + } + if ("NAVER_BLOG".equals(platform)) { + // 네이버 블로그는 상세한 내용이 필요 + return requirement != null && requirement.length() >= 20; + } + return true; + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java new file mode 100644 index 0000000..0acf9ec --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -0,0 +1,383 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 생성 응답 DTO + * + * AI를 통해 SNS 콘텐츠를 생성한 후 클라이언트에게 반환되는 응답 정보입니다. + * 생성된 콘텐츠의 기본 정보와 함께 사용자가 추가 편집할 수 있는 정보를 포함합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 응답") +public class SnsContentCreateResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "생성된 콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "대상 플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== AI 생성 콘텐츠 ==================== + + @Schema(description = "AI가 생성한 콘텐츠 제목", + example = "맛있는 신메뉴를 소개합니다! ✨") + private String title; + + @Schema(description = "AI가 생성한 콘텐츠 내용", + example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫") + private String content; + + @Schema(description = "AI가 생성한 해시태그 목록", + example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]") + private List hashtags; + + // ==================== 플랫폼별 최적화 정보 ==================== + + @Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280") + private Integer contentLength; + + @Schema(description = "플랫폼별 권장 해시태그 개수", example = "8") + private Integer recommendedHashtagCount; + + @Schema(description = "플랫폼별 최대 해시태그 개수", example = "15") + private Integer maxHashtagCount; + + // ==================== 생성 조건 정보 ==================== + + @Schema(description = "콘텐츠 생성에 사용된 조건들") + private GenerationConditionsDto generationConditions; + + // ==================== 상태 및 메타데이터 ==================== + + @Schema(description = "생성 상태", example = "DRAFT", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"}) + private String status; + + @Schema(description = "생성 일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "AI 모델 버전", example = "gpt-4-turbo") + private String aiModelVersion; + + @Schema(description = "생성 시간 (초)", example = "3.5") + private Double generationTimeSeconds; + + // ==================== 추가 정보 ==================== + + @Schema(description = "업로드된 원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85") + private Integer qualityScore; + + @Schema(description = "예상 참여율 (%)", example = "12.5") + private Double expectedEngagementRate; + + @Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개") + private String category; + + @Schema(description = "보정된 이미지 URL 목록") + private List fixedImages; + + // ==================== 편집 가능 여부 ==================== + + @Schema(description = "제목 편집 가능 여부", example = "true") + @Builder.Default + private Boolean titleEditable = true; + + @Schema(description = "내용 편집 가능 여부", example = "true") + @Builder.Default + private Boolean contentEditable = true; + + @Schema(description = "해시태그 편집 가능 여부", example = "true") + @Builder.Default + private Boolean hashtagsEditable = true; + + // ==================== 대안 콘텐츠 ==================== + + @Schema(description = "대안 제목 목록 (사용자 선택용)") + private List alternativeTitles; + + @Schema(description = "대안 해시태그 세트 목록") + private List> alternativeHashtagSets; + + // ==================== 내부 DTO 클래스 ==================== + + /** + * 콘텐츠 생성 조건 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class GenerationConditionsDto { + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 플랫폼별 콘텐츠 최적화 여부 확인 + * + * @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true + */ + public boolean isOptimizedForPlatform() { + if (content == null || hashtags == null) { + return false; + } + + // 플랫폼별 최적화 기준 + switch (platform.toUpperCase()) { + case "INSTAGRAM": + return content.length() <= 2200 && + hashtags.size() <= 15 && + hashtags.size() >= 5; + case "NAVER_BLOG": + return content.length() >= 300 && + hashtags.size() <= 10 && + hashtags.size() >= 3; + case "FACEBOOK": + return content.length() <= 500 && + hashtags.size() <= 5; + default: + return true; + } + } + + /** + * 고품질 콘텐츠 여부 확인 + * + * @return 품질 점수가 80점 이상이면 true + */ + public boolean isHighQuality() { + return qualityScore != null && qualityScore >= 80; + } + + /** + * 참여율 예상 등급 반환 + * + * @return 예상 참여율 등급 (HIGH, MEDIUM, LOW) + */ + public String getExpectedEngagementLevel() { + if (expectedEngagementRate == null) { + return "UNKNOWN"; + } + + if (expectedEngagementRate >= 15.0) { + return "HIGH"; + } else if (expectedEngagementRate >= 8.0) { + return "MEDIUM"; + } else { + return "LOW"; + } + } + + /** + * 해시태그를 문자열로 변환 (# 포함) + * + * @return #으로 시작하는 해시태그 문자열 + */ + public String getHashtagsAsString() { + if (hashtags == null || hashtags.isEmpty()) { + return ""; + } + + return hashtags.stream() + .map(tag -> "#" + tag) + .reduce((a, b) -> a + " " + b) + .orElse(""); + } + + /** + * 콘텐츠 요약 생성 + * + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public String getContentSummary(int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 플랫폼별 최적화 제안사항 반환 + * + * @return 최적화 제안사항 목록 + */ + public List getOptimizationSuggestions() { + List suggestions = new java.util.ArrayList<>(); + + if (!isOptimizedForPlatform()) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": + if (content != null && content.length() > 2200) { + suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() > 15) { + suggestions.add("해시태그를 15개 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() < 5) { + suggestions.add("해시태그를 5개 이상 추가해주세요."); + } + break; + case "NAVER_BLOG": + if (content != null && content.length() < 300) { + suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요."); + } + if (hashtags != null && hashtags.size() > 10) { + suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다."); + } + break; + case "FACEBOOK": + if (content != null && content.length() > 500) { + suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다."); + } + break; + } + } + + return suggestions; + } + + // ==================== 팩토리 메서드 ==================== + + /** + * 도메인 엔티티에서 SnsContentCreateResponse 생성 + * + * @param content 콘텐츠 도메인 엔티티 + * @param aiMetadata AI 생성 메타데이터 + * @return SnsContentCreateResponse + */ + public static SnsContentCreateResponse fromDomain( + com.won.smarketing.content.domain.model.Content content, + AiGenerationMetadata aiMetadata) { + + SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .originalImages(content.getImages()); + + // 생성 조건 정보 설정 + if (content.getCreationConditions() != null) { + builder.generationConditions(GenerationConditionsDto.builder() + //.targetAudience(content.getCreationConditions().getTargetAudience()) + .eventName(content.getCreationConditions().getEventName()) + .toneAndManner(content.getCreationConditions().getToneAndManner()) + .promotionType(content.getCreationConditions().getPromotionType()) + .emotionIntensity(content.getCreationConditions().getEmotionIntensity()) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .build()); + } + + // AI 메타데이터 설정 + if (aiMetadata != null) { + builder.aiModelVersion(aiMetadata.getModelVersion()) + .generationTimeSeconds(aiMetadata.getGenerationTime()) + .qualityScore(aiMetadata.getQualityScore()) + .expectedEngagementRate(aiMetadata.getExpectedEngagementRate()) + .alternativeTitles(aiMetadata.getAlternativeTitles()) + .alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets()); + } + + // 플랫폼별 최적화 정보 설정 + SnsContentCreateResponse response = builder.build(); + response.setContentLength(response.getContent() != null ? response.getContent().length() : 0); + response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name())); + response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name())); + + return response; + } + + /** + * 플랫폼별 권장 해시태그 개수 반환 + */ + private static Integer getRecommendedHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 8; + case "NAVER_BLOG": return 5; + case "FACEBOOK": return 3; + case "KAKAO_STORY": return 5; + default: return 5; + } + } + + /** + * 플랫폼별 최대 해시태그 개수 반환 + */ + private static Integer getMaxHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 15; + case "NAVER_BLOG": return 10; + case "FACEBOOK": return 5; + case "KAKAO_STORY": return 8; + default: return 10; + } + } + + // ==================== AI 생성 메타데이터 DTO ==================== + + /** + * AI 생성 메타데이터 + * AI 생성 과정에서 나온 부가 정보들 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AiGenerationMetadata { + private String modelVersion; + private Double generationTime; + private Integer qualityScore; + private Double expectedEngagementRate; + private List alternativeTitles; + private List> alternativeHashtagSets; + private String category; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java new file mode 100644 index 0000000..9adb6c8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java @@ -0,0 +1,79 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "SNS 콘텐츠 저장 요청") +public class SnsContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "플랫폼", example = "INSTAGRAM", required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!") + private String finalTitle; + + @Schema(description = "최종 콘텐츠 내용") + private String finalContent; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "메뉴소개") + private String category; + + @Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml new file mode 100644 index 0000000..10dc73d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${SERVER_PORT:8083} + +spring: + application: + name: marketing-content-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + +logging: + level: + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-java/member/Jenkinsfile b/smarketing-java/member/Jenkinsfile new file mode 100644 index 0000000..3267d52 --- /dev/null +++ b/smarketing-java/member/Jenkinsfile @@ -0,0 +1,81 @@ +pipeline { + agent any + + environment { + ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io' + IMAGE_NAME = 'member' + MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git' + MANIFEST_PATH = 'member/deployment.yaml' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build') { + steps { + dir('member') { + sh './gradlew clean build -x test' + } + } + } + + stage('Test') { + steps { + dir('member') { + sh './gradlew test' + } + } + } + + stage('Build Docker Image') { + steps { + script { + def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}" + def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}" + + dir('member') { + sh "docker build -t ${fullImageName} ." + } + + withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) { + sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}" + sh "docker push ${fullImageName}" + } + + env.IMAGE_TAG = imageTag + env.FULL_IMAGE_NAME = fullImageName + } + } + } + + stage('Update Manifest') { + steps { + withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) { + sh ''' + git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo + cd manifest-repo + + # Update image tag in deployment.yaml + sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH} + + git config user.email "jenkins@smarketing.com" + git config user.name "Jenkins" + git add . + git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}" + git push origin main + ''' + } + } + } + } + + post { + always { + cleanWs() + } + } +} diff --git a/smarketing-java/member/build.gradle b/smarketing-java/member/build.gradle new file mode 100644 index 0000000..c75e760 --- /dev/null +++ b/smarketing-java/member/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation project(':common') + // 데이터베이스 의존성 + runtimeOnly 'org.postgresql:postgresql' +} \ No newline at end of file diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java new file mode 100644 index 0000000..d8f2305 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.member; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 회원 서비스 메인 애플리케이션 클래스 + * Spring Boot 애플리케이션의 진입점 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.member", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.member.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.member.repository"}) +public class MemberServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MemberServiceApplication.class, args); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java new file mode 100644 index 0000000..4d5037a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.member.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java new file mode 100644 index 0000000..d3b1155 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java @@ -0,0 +1,64 @@ +package com.won.smarketing.member.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.member.dto.*; +import com.won.smarketing.member.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 인증을 위한 REST API 컨트롤러 + * 로그인, 로그아웃, 토큰 갱신 기능 제공 + */ +@Tag(name = "인증 관리", description = "로그인, 로그아웃, 토큰 관리 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 성공 응답 (토큰 포함) + */ + @Operation(summary = "로그인", description = "사용자 ID와 패스워드로 로그인합니다.") + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.success(response, "로그인이 완료되었습니다.")); + } + + /** + * 로그아웃 + * + * @param request 로그아웃 요청 정보 + * @return 로그아웃 성공 응답 + */ + @Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하여 로그아웃합니다.") + @PostMapping("/logout") + public ResponseEntity> logout(@Valid @RequestBody LogoutRequest request) { + authService.logout(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.success(null, "로그아웃이 완료되었습니다.")); + } + + /** + * 토큰 갱신 + * + * @param request 토큰 갱신 요청 정보 + * @return 새로운 토큰 정보 + */ + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") + @PostMapping("/refresh") + public ResponseEntity> refresh(@Valid @RequestBody TokenRefreshRequest request) { + TokenResponse response = authService.refresh(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.success(response, "토큰이 갱신되었습니다.")); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java new file mode 100644 index 0000000..e73728d --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java @@ -0,0 +1,120 @@ +package com.won.smarketing.member.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.member.dto.DuplicateCheckResponse; +import com.won.smarketing.member.dto.PasswordValidationRequest; +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; +import com.won.smarketing.member.service.MemberService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 회원 관리를 위한 REST API 컨트롤러 + * 회원가입, 중복 확인, 패스워드 검증 기능 제공 + */ +@Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API") +@RestController +@RequestMapping("/api/member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberService memberService; + + /** + * 회원가입 + * + * @param request 회원가입 요청 정보 + * @return 회원가입 성공 응답 + */ + @Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { + memberService.register(request); + return ResponseEntity.ok(ApiResponse.success(null, "회원가입이 완료되었습니다.")); + } + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 확인 결과 + */ + @Operation(summary = "사용자 ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/user-id") + public ResponseEntity> checkUserIdDuplicate( + @Parameter(description = "확인할 사용자 ID", required = true) + @RequestParam String userId) { + + boolean isDuplicate = memberService.checkDuplicate(userId); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 사용자 ID입니다.") + : DuplicateCheckResponse.available("사용 가능한 사용자 ID입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 확인 결과 + */ + @Operation(summary = "이메일 중복 확인", description = "이메일의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/email") + public ResponseEntity> checkEmailDuplicate( + @Parameter(description = "확인할 이메일", required = true) + @RequestParam String email) { + + boolean isDuplicate = memberService.checkEmailDuplicate(email); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 이메일입니다.") + : DuplicateCheckResponse.available("사용 가능한 이메일입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 확인 결과 + */ + @Operation(summary = "사업자번호 중복 확인", description = "사업자번호의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/business-number") + public ResponseEntity> checkBusinessNumberDuplicate( + @Parameter(description = "확인할 사업자번호", required = true) + @RequestParam String businessNumber) { + + boolean isDuplicate = memberService.checkBusinessNumberDuplicate(businessNumber); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 등록된 사업자번호입니다.") + : DuplicateCheckResponse.available("사용 가능한 사업자번호입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 패스워드 유효성 검증 + * + * @param request 패스워드 검증 요청 + * @return 패스워드 검증 결과 + */ + @Operation(summary = "패스워드 검증", description = "패스워드가 규칙을 만족하는지 확인합니다.") + @PostMapping("/validate-password") + public ResponseEntity> validatePassword( + @Valid @RequestBody PasswordValidationRequest request) { + + ValidationResponse response = memberService.validatePassword(request.getPassword()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} + + + diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java new file mode 100644 index 0000000..cf9e56b --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java @@ -0,0 +1,54 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 중복 확인 응답 DTO + * 사용자 ID, 이메일 등의 중복 확인 결과를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "중복 확인 응답") +public class DuplicateCheckResponse { + + @Schema(description = "중복 여부", example = "false") + private boolean isDuplicate; + + @Schema(description = "메시지", example = "사용 가능한 ID입니다.") + private String message; + + /** + * 중복된 경우의 응답 생성 + * + * @param message 메시지 + * @return 중복 응답 + */ + public static DuplicateCheckResponse duplicate(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(true) + .message(message) + .build(); + } + + /** + * 사용 가능한 경우의 응답 생성 + * + * @param message 메시지 + * @return 사용 가능 응답 + */ + public static DuplicateCheckResponse available(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(false) + .message(message) + .build(); + } +} + + + diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java new file mode 100644 index 0000000..d55ee0a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java @@ -0,0 +1,26 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 로그인 요청 DTO + * 로그인 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "로그인 요청") +public class LoginRequest { + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") + private String userId; + + @Schema(description = "패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다") + private String password; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java new file mode 100644 index 0000000..3c71e94 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java @@ -0,0 +1,47 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 로그인 응답 DTO + * 로그인 성공 시 토큰 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그인 응답") +public class LoginResponse { + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") + private long expiresIn; + + @Schema(description = "사용자 정보") + private UserInfo userInfo; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "사용자 정보") + public static class UserInfo { + @Schema(description = "사용자 ID", example = "user123") + private String userId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java new file mode 100644 index 0000000..99008bf --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java @@ -0,0 +1,25 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.NotBlank; + +/** + * 로그아웃 요청 DTO + * 로그아웃 시 무효화할 Refresh Token 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그아웃 요청") +public class LogoutRequest { + + @Schema(description = "무효화할 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "Refresh Token은 필수입니다.") + private String refreshToken; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java new file mode 100644 index 0000000..b2d96aa --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java @@ -0,0 +1,22 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 패스워드 검증 요청 DTO + * 패스워드 규칙 검증을 위한 요청 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "패스워드 검증 요청") +public class PasswordValidationRequest { + + @Schema(description = "검증할 패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다") + private String password; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java new file mode 100644 index 0000000..b0cad33 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java @@ -0,0 +1,49 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 회원가입 요청 DTO + * 회원가입 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회원가입 요청") +public class RegisterRequest { + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") + @Size(min = 4, max = 20, message = "사용자 ID는 4-20자 사이여야 합니다") + @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 사용 가능합니다") + private String userId; + + @Schema(description = "패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다") + @Size(min = 8, max = 20, message = "패스워드는 8-20자 사이여야 합니다") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$", + message = "패스워드는 영문, 숫자, 특수문자를 포함해야 합니다") + private String password; + + @Schema(description = "이름", example = "홍길동", required = true) + @NotBlank(message = "이름은 필수입니다") + @Size(max = 50, message = "이름은 50자 이하여야 합니다") + private String name; + + @Schema(description = "사업자등록번호", example = "1234567890") + @Pattern(regexp = "^\\d{10}$", message = "사업자등록번호는 10자리 숫자여야 합니다") + private String businessNumber; + + @Schema(description = "이메일", example = "user@example.com", required = true) + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + private String email; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..7278ab5 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java @@ -0,0 +1,22 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 토큰 갱신 요청 DTO + * 리프레시 토큰을 사용한 액세스 토큰 갱신 요청 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "토큰 갱신 요청") +public class TokenRefreshRequest { + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "리프레시 토큰은 필수입니다") + private String refreshToken; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java new file mode 100644 index 0000000..a750def --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java @@ -0,0 +1,28 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 토큰 응답 DTO + * 토큰 갱신 시 새로운 토큰 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "토큰 응답") +public class TokenResponse { + + @Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "새로운 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") + private long expiresIn; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java new file mode 100644 index 0000000..4808fec --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java @@ -0,0 +1,58 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 검증 응답 DTO + * 패스워드 등의 검증 결과를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "검증 응답") +public class ValidationResponse { + + @Schema(description = "유효성 여부", example = "true") + private boolean isValid; + + @Schema(description = "메시지", example = "사용 가능한 패스워드입니다.") + private String message; + + @Schema(description = "오류 목록", example = "[\"영문이 포함되어야 합니다\", \"숫자가 포함되어야 합니다\"]") + private List errors; + + /** + * 유효한 경우의 응답 생성 + * + * @param message 메시지 + * @return 유효 응답 + */ + public static ValidationResponse valid(String message) { + return ValidationResponse.builder() + .isValid(true) + .message(message) + .build(); + } + + /** + * 유효하지 않은 경우의 응답 생성 + * + * @param message 메시지 + * @param errors 오류 목록 + * @return 무효 응답 + */ + public static ValidationResponse invalid(String message, List errors) { + return ValidationResponse.builder() + .isValid(false) + .message(message) + .errors(errors) + .build(); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java new file mode 100644 index 0000000..89bb592 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java @@ -0,0 +1,82 @@ +package com.won.smarketing.member.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 회원 엔티티 + * 회원의 기본 정보와 사업자 정보를 관리 + */ +@Entity +@Table(name = "members") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(name = "user_id", nullable = false, unique = true, length = 50) + private String userId; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "business_number", length = 15, unique = true) + private String businessNumber; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 회원 정보 업데이트 + * + * @param name 이름 + * @param email 이메일 + * @param businessNumber 사업자번호 + */ + public void updateProfile(String name, String email, String businessNumber) { + if (name != null && !name.trim().isEmpty()) { + this.name = name; + } + if (email != null && !email.trim().isEmpty()) { + this.email = email; + } + if (businessNumber != null && !businessNumber.trim().isEmpty()) { + this.businessNumber = businessNumber; + } + } + + /** + * 패스워드 변경 + * + * @param encodedPassword 암호화된 패스워드 + */ + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java new file mode 100644 index 0000000..eec42ea --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java @@ -0,0 +1,47 @@ +package com.won.smarketing.member.repository; + +import com.won.smarketing.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 회원 정보 데이터 접근을 위한 Repository + * JPA를 사용한 회원 CRUD 작업 처리 + */ +@Repository +public interface MemberRepository extends JpaRepository { + + /** + * 사용자 ID로 회원 조회 + * + * @param userId 사용자 ID + * @return 회원 정보 (Optional) + */ + Optional findByUserId(String userId); + + /** + * 사용자 ID 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 이메일 존재 여부 확인 + * + * @param email 이메일 + * @return 존재 여부 + */ + boolean existsByEmail(String email); + + /** + * 사업자번호 존재 여부 확인 + * + * @param businessNumber 사업자번호 + * @return 존재 여부 + */ + boolean existsByBusinessNumber(String businessNumber); +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java new file mode 100644 index 0000000..c73bc1f --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java @@ -0,0 +1,35 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.TokenResponse; + +/** + * 인증 서비스 인터페이스 + * 로그인, 로그아웃, 토큰 갱신 관련 비즈니스 로직 정의 + */ +public interface AuthService { + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 응답 정보 (토큰 포함) + */ + LoginResponse login(LoginRequest request); + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + */ + void logout(String refreshToken); + + /** + * 토큰 갱신 + * + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 + */ + TokenResponse refresh(String refreshToken); +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java new file mode 100644 index 0000000..694e93a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java @@ -0,0 +1,166 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.common.security.JwtTokenProvider; +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.TokenResponse; +import com.won.smarketing.member.entity.Member; +import com.won.smarketing.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.concurrent.TimeUnit; + +/** + * 인증 서비스 구현체 + * 로그인, 로그아웃, 토큰 갱신 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthServiceImpl implements AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RedisTemplate redisTemplate; + + private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; + private static final String BLACKLIST_PREFIX = "blacklist:"; + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 응답 정보 (토큰 포함) + */ + @Override + @Transactional + public LoginResponse login(LoginRequest request) { + log.info("로그인 시도: {}", request.getUserId()); + + // 회원 조회 + Member member = memberRepository.findByUserId(request.getUserId()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 패스워드 검증 + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + System.out.println(passwordEncoder.encode(request.getPassword())); + System.out.println(passwordEncoder.encode(member.getPassword())); + + throw new BusinessException(ErrorCode.INVALID_PASSWORD); + } + + // 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); + + log.info("{} access token 발급: {}", request.getUserId(), accessToken); + + // 리프레시 토큰을 Redis에 저장 (7일) + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + member.getUserId(), + refreshToken, + 7, + TimeUnit.DAYS + ); + + log.info("로그인 성공: {}", request.getUserId()); + + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .userInfo(LoginResponse.UserInfo.builder() + .userId(member.getUserId()) + .name(member.getName()) + .email(member.getEmail()) + .build()) + .build(); + } + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + */ + @Override + @Transactional + public void logout(String refreshToken) { + try { + if (jwtTokenProvider.validateToken(refreshToken)) { + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + + log.info("로그아웃 완료: {}", userId); + } + } catch (Exception ex) { + log.warn("로그아웃 처리 중 오류 발생: {}", ex.getMessage()); + // 로그아웃은 실패해도 클라이언트에게는 성공으로 응답 + } + } + + /** + * 토큰 갱신 + * + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 + */ + @Override + @Transactional + public TokenResponse refresh(String refreshToken) { + // 토큰 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 블랙리스트 확인 + if (redisTemplate.hasKey(BLACKLIST_PREFIX + refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에 저장된 리프레시 토큰과 비교 + String storedRefreshToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + if (!refreshToken.equals(storedRefreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 회원 존재 확인 + if (!memberRepository.existsByUserId(userId)) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + + // 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + + // 새로운 리프레시 토큰을 Redis에 저장 + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + userId, + newRefreshToken, + 7, + TimeUnit.DAYS + ); + + // 기존 리프레시 토큰 삭제 + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + + log.info("토큰 갱신 완료: {}", userId); + + return TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java new file mode 100644 index 0000000..c1e456f --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java @@ -0,0 +1,50 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; + +/** + * 회원 서비스 인터페이스 + * 회원 관리 관련 비즈니스 로직 정의 + */ +public interface MemberService { + + /** + * 회원 등록 + * + * @param request 회원가입 요청 정보 + */ + void register(RegisterRequest request); + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 + */ + boolean checkDuplicate(String userId); + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + boolean checkEmailDuplicate(String email); + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + boolean checkBusinessNumberDuplicate(String businessNumber); + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 검증 결과 + */ + ValidationResponse validatePassword(String password); +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..8c730d2 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java @@ -0,0 +1,146 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; +import com.won.smarketing.member.entity.Member; +import com.won.smarketing.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 회원 서비스 구현체 + * 회원 등록, 중복 확인, 패스워드 검증 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + // 패스워드 검증 패턴 + private static final Pattern LETTER_PATTERN = Pattern.compile(".*[a-zA-Z].*"); + private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*"); + private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[@$!%*?&].*"); + + /** + * 회원 등록 + * + * @param request 회원가입 요청 정보 + */ + @Override + @Transactional + public void register(RegisterRequest request) { + log.info("회원 등록 시작: {}", request.getUserId()); + + // 중복 확인 + if (memberRepository.existsByUserId(request.getUserId())) { + throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); + } + + if (memberRepository.existsByEmail(request.getEmail())) { + throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); + } + + if (request.getBusinessNumber() != null && + memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { + throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); + } + + // 회원 엔티티 생성 및 저장 + Member member = Member.builder() + .userId(request.getUserId()) + .password(passwordEncoder.encode(request.getPassword())) + .name(request.getName()) + .businessNumber(request.getBusinessNumber()) + .email(request.getEmail()) + .build(); + + memberRepository.save(member); + log.info("회원 등록 완료: {}", request.getUserId()); + } + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 + */ + @Override + public boolean checkDuplicate(String userId) { + return memberRepository.existsByUserId(userId); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + @Override + public boolean checkEmailDuplicate(String email) { + return memberRepository.existsByEmail(email); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + @Override + public boolean checkBusinessNumberDuplicate(String businessNumber) { + if (businessNumber == null || businessNumber.trim().isEmpty()) { + return false; + } + return memberRepository.existsByBusinessNumber(businessNumber); + } + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 검증 결과 + */ + @Override + public ValidationResponse validatePassword(String password) { + List errors = new ArrayList<>(); + + // 길이 검증 + if (password.length() < 8 || password.length() > 20) { + errors.add("패스워드는 8-20자 사이여야 합니다"); + } + + // 영문 포함 여부 + if (!LETTER_PATTERN.matcher(password).matches()) { + errors.add("영문이 포함되어야 합니다"); + } + + // 숫자 포함 여부 + if (!DIGIT_PATTERN.matcher(password).matches()) { + errors.add("숫자가 포함되어야 합니다"); + } + + // 특수문자 포함 여부 + if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) { + errors.add("특수문자(@$!%*?&)가 포함되어야 합니다"); + } + + if (errors.isEmpty()) { + return ValidationResponse.valid("사용 가능한 패스워드입니다."); + } else { + return ValidationResponse.invalid("패스워드 규칙을 확인해 주세요.", errors); + } + } +} diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml new file mode 100644 index 0000000..511b56f --- /dev/null +++ b/smarketing-java/member/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${MEMBER_PORT:8081} + +spring: + application: + name: member-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MemberDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6380} + password: ${REDIS_PASSWORD:} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + +logging: + level: + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-java/member/src/main/resources/data.sql b/smarketing-java/member/src/main/resources/data.sql new file mode 100644 index 0000000..8404deb --- /dev/null +++ b/smarketing-java/member/src/main/resources/data.sql @@ -0,0 +1,18 @@ +INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at) +VALUES + (DEFAULT, 'testuser1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김소상', '123-45-67890', 'test1@smarketing.com', NOW(), NOW()), + (DEFAULT, 'testuser2', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이점주', '234-56-78901', 'test2@smarketing.com', NOW(), NOW()), + (DEFAULT, 'testuser3', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '박카페', '345-67-89012', 'test3@smarketing.com', NOW(), NOW()), + (DEFAULT, 'cafeowner1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '최카페', '456-78-90123', 'cafe@smarketing.com', NOW(), NOW()), + (DEFAULT, 'restaurant1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '정식당', '567-89-01234', 'restaurant@smarketing.com', NOW(), NOW()) +ON CONFLICT (user_id) DO NOTHING; + +-- 이메일 중복 방지를 위한 추가 체크 +INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at) +VALUES + (DEFAULT, 'bakery1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김베이커리', '678-90-12345', 'bakery@smarketing.com', NOW(), NOW()), + (DEFAULT, 'chicken1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '한치킨', '789-01-23456', 'chicken@smarketing.com', NOW(), NOW()), + (DEFAULT, 'pizza1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이피자', '890-12-34567', 'pizza@smarketing.com', NOW(), NOW()), + (DEFAULT, 'dessert1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '달디저트', '901-23-45678', 'dessert@smarketing.com', NOW(), NOW()), + (DEFAULT, 'beauty1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '미뷰티샵', '012-34-56789', 'beauty@smarketing.com', NOW(), NOW()) +ON CONFLICT (user_id) DO NOTHING; diff --git a/smarketing-java/settings.gradle b/smarketing-java/settings.gradle new file mode 100644 index 0000000..54fbe0d --- /dev/null +++ b/smarketing-java/settings.gradle @@ -0,0 +1,6 @@ +rootProject.name = 'smarketing' +include 'common' +include 'member' +include 'store' +include 'marketing-content' +include 'ai-recommend' \ No newline at end of file diff --git a/smarketing-java/store/build.gradle b/smarketing-java/store/build.gradle new file mode 100644 index 0000000..dd9e26d --- /dev/null +++ b/smarketing-java/store/build.gradle @@ -0,0 +1,8 @@ +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' + + // Azure Blob Storage 의존성 추가 + implementation 'com.azure:azure-storage-blob:12.25.0' + implementation 'com.azure:azure-identity:1.11.1' +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java new file mode 100644 index 0000000..8d9a7a5 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.store; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 매장 서비스 메인 애플리케이션 클래스 + * Spring Boot 애플리케이션의 진입점 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.store", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.store.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.store.repository"}) +public class StoreServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(StoreServiceApplication.class, args); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java new file mode 100644 index 0000000..ffa6bef --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java @@ -0,0 +1,72 @@ +// store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java +package com.won.smarketing.store.config; + +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.common.StorageSharedKeyCredential; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Azure Blob Storage 설정 클래스 + * Azure Blob Storage와의 연결을 위한 설정 + */ +@Configuration +@Slf4j +public class AzureBlobStorageConfig { + + @Value("${azure.storage.account-name}") + private String accountName; + + @Value("${azure.storage.account-key:}") + private String accountKey; + + @Value("${azure.storage.endpoint:}") + private String endpoint; + + /** + * Azure Blob Storage Service Client 생성 + * + * @return BlobServiceClient 인스턴스 + */ + @Bean + public BlobServiceClient blobServiceClient() { + try { + // Managed Identity 사용 시 (Azure 환경에서 권장) + if (accountKey == null || accountKey.isEmpty()) { + log.info("Azure Blob Storage 연결 - Managed Identity 사용"); + return new BlobServiceClientBuilder() + .endpoint(getEndpoint()) + .credential(new DefaultAzureCredentialBuilder().build()) + .buildClient(); + } + + // Account Key 사용 시 (개발 환경용) + log.info("Azure Blob Storage 연결 - Account Key 사용"); + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey); + return new BlobServiceClientBuilder() + .endpoint(getEndpoint()) + .credential(credential) + .buildClient(); + + } catch (Exception e) { + log.error("Azure Blob Storage 클라이언트 생성 실패", e); + throw new RuntimeException("Azure Blob Storage 연결 실패", e); + } + } + + /** + * Storage Account 엔드포인트 URL 생성 + * + * @return 엔드포인트 URL + */ + private String getEndpoint() { + if (endpoint != null && !endpoint.isEmpty()) { + return endpoint; + } + return String.format("https://%s.blob.core.windows.net", accountName); + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java new file mode 100644 index 0000000..3c7e2f9 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java @@ -0,0 +1,31 @@ +package com.won.smarketing.store.config; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { + private String category; + + @Schema(description = "가격", example = "4500", required = true) + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") + private String image; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java new file mode 100644 index 0000000..adadce8 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java @@ -0,0 +1,155 @@ +// store/src/main/java/com/won/smarketing/store/controller/ImageController.java +package com.won.smarketing.store.controller; + +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.service.BlobStorageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 이미지 업로드 API 컨트롤러 + * 메뉴 이미지, 매장 이미지 업로드 기능 제공 + */ +@RestController +@RequestMapping("/api/images") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "이미지 업로드 API", description = "메뉴 및 매장 이미지 업로드 관리") +public class ImageController { + + private final BlobStorageService blobStorageService; + + /** + * 메뉴 이미지 업로드 + * + * @param menuId 메뉴 ID + * @param file 업로드할 이미지 파일 + * @return 업로드 결과 + */ + @PostMapping(value = "/menu/{menuId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "메뉴 이미지 업로드", description = "메뉴의 이미지를 Azure Blob Storage에 업로드합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이미지 업로드 성공", + content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 형식, 크기 등)"), + @ApiResponse(responseCode = "404", description = "메뉴를 찾을 수 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity uploadMenuImage( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId, + @Parameter(description = "업로드할 이미지 파일", required = true) + @RequestParam("file") MultipartFile file) { + + log.info("메뉴 이미지 업로드 요청 - 메뉴 ID: {}, 파일: {}", menuId, file.getOriginalFilename()); + + MenuResponse response = blobStorageService.uploadMenuImage(file, menuId); + + return ResponseEntity.ok(response); + } + + /** + * 매장 이미지 업로드 + * + * @param storeId 매장 ID + * @param file 업로드할 이미지 파일 + * @return 업로드 결과 + */ + @PostMapping(value = "/store/{storeId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "매장 이미지 업로드", description = "매장의 이미지를 Azure Blob Storage에 업로드합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이미지 업로드 성공", + content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 형식, 크기 등)"), + @ApiResponse(responseCode = "404", description = "매장을 찾을 수 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity uploadStoreImage( + @Parameter(description = "매장 ID", required = true) + @PathVariable Long storeId, + @Parameter(description = "업로드할 이미지 파일", required = true) + @RequestParam("file") MultipartFile file) { + + log.info("매장 이미지 업로드 요청 - 매장 ID: {}, 파일: {}", storeId, file.getOriginalFilename()); + StoreResponse response = blobStorageService.uploadStoreImage(file, storeId); + + return ResponseEntity.ok(response); + } + + /** + * 이미지 삭제 + * + * @param imageUrl 삭제할 이미지 URL + * @return 삭제 결과 + */ + //@DeleteMapping + //@Operation(summary = "이미지 삭제", description = "Azure Blob Storage에서 이미지를 삭제합니다.") +// @ApiResponses(value = { +// @ApiResponse(responseCode = "200", description = "이미지 삭제 성공"), +// @ApiResponse(responseCode = "400", description = "잘못된 요청"), +// @ApiResponse(responseCode = "404", description = "이미지를 찾을 수 없음"), +// @ApiResponse(responseCode = "500", description = "서버 오류") +// }) +// public ResponseEntity deleteImage( +// @Parameter(description = "삭제할 이미지 URL", required = true) +// @RequestParam String imageUrl) { +// +// log.info("이미지 삭제 요청 - URL: {}", imageUrl); +// +// try { +// boolean deleted = blobStorageService.deleteFile(imageUrl); +// +// ImageUploadResponse response = ImageUploadResponse.builder() +// .imageUrl(imageUrl) +// .success(deleted) +// .message(deleted ? "이미지 삭제가 완료되었습니다." : "삭제할 이미지를 찾을 수 없습니다.") +// .build(); +// +// return ResponseEntity.ok(response); +// +// } catch (Exception e) { +// log.error("이미지 삭제 실패 - URL: {}", imageUrl, e); +// +// ImageUploadResponse response = ImageUploadResponse.builder() +// .imageUrl(imageUrl) +// .success(false) +// .message("이미지 삭제에 실패했습니다: " + e.getMessage()) +// .build(); +// +// return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); +// } +// } + + /** + * URL에서 파일명 추출 + * + * @param url 파일 URL + * @return 파일명 + */ + private String extractFileNameFromUrl(String url) { + if (url == null || url.isEmpty()) { + return null; + } + + try { + return url.substring(url.lastIndexOf('/') + 1); + } catch (Exception e) { + log.warn("URL에서 파일명 추출 실패: {}", url); + return null; + } + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java new file mode 100644 index 0000000..906a96f --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java @@ -0,0 +1,98 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import com.won.smarketing.store.service.BlobStorageService; +import com.won.smarketing.store.service.MenuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 메뉴 관리를 위한 REST API 컨트롤러 + * 메뉴 등록, 조회, 수정, 삭제 기능 제공 + */ +@Tag(name = "메뉴 관리", description = "메뉴 정보 관리 API") +@RestController +@RequestMapping("/api/menu") +@RequiredArgsConstructor +public class MenuController { + + private final MenuService menuService; + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + @Operation(summary = "메뉴 등록", description = "새로운 메뉴를 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody MenuCreateRequest request) { + MenuResponse response = menuService.register(request); + return ResponseEntity.ok(ApiResponse.success(response, "메뉴가 성공적으로 등록되었습니다.")); + } + + /** + * 메뉴 목록 조회 + * + * @param storeId 메뉴 카테고리 + * @return 메뉴 목록 + */ + @Operation(summary = "메뉴 목록 조회", description = "메뉴 목록을 조회합니다. 카테고리별 필터링 가능합니다.") + @GetMapping + public ResponseEntity>> getMenus( + @Parameter(description = "가게 ID") + @RequestParam(required = true) Long storeId) { + List response = menuService.getMenus(storeId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + @Operation(summary = "메뉴 수정", description = "메뉴 정보를 수정합니다.") + @PutMapping("/{menuId}") + public ResponseEntity> updateMenu( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId, + @Valid @RequestBody MenuUpdateRequest request) { + MenuResponse response = menuService.updateMenu(menuId, request); + return ResponseEntity.ok(ApiResponse.success(response, "메뉴가 성공적으로 수정되었습니다.")); + } + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + * @return 삭제 성공 응답 + */ + @Operation(summary = "메뉴 삭제", description = "메뉴를 삭제합니다.") + @DeleteMapping("/{menuId}") + public ResponseEntity> deleteMenu( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId) { + menuService.deleteMenu(menuId); + return ResponseEntity.ok(ApiResponse.success(null, "메뉴가 성공적으로 삭제되었습니다.")); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java new file mode 100644 index 0000000..eab1e76 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java @@ -0,0 +1,43 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.service.SalesService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 매출 정보를 위한 REST API 컨트롤러 + * 매출 조회 기능 제공 + */ +@Tag(name = "매출 관리", description = "매출 정보 조회 API") +@RestController +@RequestMapping("/api/sales") +@RequiredArgsConstructor +public class SalesController { + + private final SalesService salesService; + + /** + * 매출 정보 조회 + * + * @param storeId 가게 ID + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Operation(summary = "매출 조회", description = "오늘 매출, 월간 매출, 전일 대비 매출 정보를 조회합니다.") + @GetMapping("/{storeId}") + public ResponseEntity> getSales( + @Parameter(description = "가게 ID", required = true) + @PathVariable Long storeId + ) { + SalesResponse response = salesService.getSales(storeId); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java new file mode 100644 index 0000000..3e12344 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java @@ -0,0 +1,75 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreCreateResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; +import com.won.smarketing.store.service.StoreService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 매장 관리를 위한 REST API 컨트롤러 + * 매장 등록, 조회, 수정 기능 제공 + */ +@Tag(name = "매장 관리", description = "매장 정보 관리 API") +@RestController +@RequestMapping("/api/store") +@RequiredArgsConstructor +public class StoreController { + + private final StoreService storeService; + + /** + * 매장 정보 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + @Operation(summary = "매장 등록", description = "새로운 매장 정보를 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody StoreCreateRequest request) { + StoreCreateResponse response = storeService.register(request); + return ResponseEntity.ok(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다.")); + } + + /** + * 매장 정보 조회 + * + * //@param userId 조회할 매장 ID + * @return 매장 정보 + */ + @Operation(summary = "매장 조회", description = "유저 ID로 매장 정보를 조회합니다.") + @GetMapping + public ResponseEntity> getStore( +// @Parameter(description = "유저 ID", required = true) +// @RequestParam String userId + ) { + StoreResponse response = storeService.getStore(); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 매장 정보 수정 + * + * //@param storeId 수정할 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + @Operation(summary = "매장 수정", description = "매장 정보를 수정합니다.") + @PutMapping() + public ResponseEntity> updateStore( + @Parameter(description = "매장 ID", required = true) + // @PathVariable Long storeId, + @Valid @RequestBody StoreUpdateRequest request) { + StoreResponse response = storeService.updateStore(request); + return ResponseEntity.ok(ApiResponse.success(response, "매장 정보가 성공적으로 수정되었습니다.")); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java new file mode 100644 index 0000000..8a12e4a --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java @@ -0,0 +1,25 @@ +// store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.constraints.NotNull; + +/** + * 이미지 업로드 요청 DTO + * 이미지 파일 업로드 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이미지 업로드 요청") +public class ImageUploadRequest { + + @Schema(description = "업로드할 이미지 파일", required = true) + @NotNull(message = "이미지 파일은 필수입니다") + private MultipartFile file; +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java new file mode 100644 index 0000000..e937101 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java @@ -0,0 +1,37 @@ +// store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 이미지 업로드 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이미지 업로드 응답") +public class ImageUploadResponse { + + @Schema(description = "업로드된 이미지 URL", example = "https://storage.blob.core.windows.net/menu-images/menu_123_20241201_143000_abc12345.jpg") + private String imageUrl; + + @Schema(description = "원본 파일명", example = "americano.jpg") + private String originalFileName; + + @Schema(description = "저장된 파일명", example = "menu_123_20241201_143000_abc12345.jpg") + private String savedFileName; + + @Schema(description = "파일 크기 (바이트)", example = "1024000") + private Long fileSize; + + @Schema(description = "업로드 성공 여부", example = "true") + private boolean success; + + @Schema(description = "메시지", example = "이미지 업로드가 완료되었습니다.") + private String message; +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java new file mode 100644 index 0000000..28dcb5c --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java @@ -0,0 +1,45 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 메뉴 등록 요청 DTO + * 메뉴 등록 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴 등록 요청") +public class MenuCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "메뉴명", example = "아메리카노", required = true) + @NotBlank(message = "메뉴명은 필수입니다") + @Size(max = 100, message = "메뉴명은 100자 이하여야 합니다") + private String menuName; + + @Schema(description = "카테고리", example = "커피") + @Size(max = 50, message = "카테고리는 50자 이하여야 합니다") + private String category; + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; +} + + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java new file mode 100644 index 0000000..aa9f642 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java @@ -0,0 +1,49 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 메뉴 응답 DTO + * 메뉴 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 응답") +public class MenuResponse { + + @Schema(description = "메뉴 ID", example = "1") + private Long menuId; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "메뉴명", example = "아메리카노") + private String menuName; + + @Schema(description = "카테고리", example = "커피") + private String category; + + @Schema(description = "가격", example = "4500") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + private String image; + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java new file mode 100644 index 0000000..4df4894 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -0,0 +1,38 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +/** + * 메뉴 수정 요청 DTO + * 메뉴 정보 수정 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 수정 요청 정보") +public class MenuUpdateRequest { + + @Schema(description = "메뉴명", example = "아메리카노") + @Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") + private String menuName; + + @Schema(description = "메뉴 카테고리", example = "커피") + @Size(max = 100, message = "카테고리는 100자 이하여야 합니다.") + private String category; + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + private String description; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java new file mode 100644 index 0000000..dc1e47c --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.store.dto; + +import com.won.smarketing.store.entity.Sales; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 매출 응답 DTO + * 매출 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매출 응답") +public class SalesResponse { + + @Schema(description = "오늘 매출", example = "150000") + private BigDecimal todaySales; + + @Schema(description = "월간 매출", example = "4500000") + private BigDecimal monthSales; + + @Schema(description = "전일 대비 매출 변화", example = "25000") + private BigDecimal previousDayComparison; + + @Schema(description = "전일 대비 매출 변화율 (%)", example = "15.5") + private BigDecimal previousDayChangeRate; + + @Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2") + private BigDecimal goalAchievementRate; + + @Schema(description = "일년 동안의 매출액") + private List yearSales; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java new file mode 100644 index 0000000..ebb72c6 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java @@ -0,0 +1,62 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 등록 요청 DTO + * 매장 등록 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 등록 요청") +public class StoreCreateRequest { + + @Schema(description = "매장명", example = "맛있는 카페", required = true) + @NotBlank(message = "매장명은 필수입니다") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123", required = true) + @NotBlank(message = "주소는 필수입니다") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String instaAccounts; + + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + private String blogAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; +} + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java new file mode 100644 index 0000000..bca1adf --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java @@ -0,0 +1,56 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 응답 DTO + * 매장 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 응답") +public class StoreCreateResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + +// @Schema(description = "매장명", example = "맛있는 카페") +// private String storeName; +// +// @Schema(description = "업종", example = "카페") +// private String businessType; +// +// @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") +// private String address; +// +// @Schema(description = "전화번호", example = "02-1234-5678") +// private String phoneNumber; +// +// @Schema(description = "영업시간", example = "09:00 - 22:00") +// private String businessHours; +// +// @Schema(description = "휴무일", example = "매주 일요일") +// private String closedDays; +// +// @Schema(description = "좌석 수", example = "20") +// private Integer seatCount; +// +// @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") +// private String snsAccounts; +// +// @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") +// private String description; +// +// @Schema(description = "등록일시", example = "2024-01-15T10:30:00") +// private LocalDateTime createdAt; +// +// @Schema(description = "수정일시", example = "2024-01-15T10:30:00") +// private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java new file mode 100644 index 0000000..9c0bce0 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java @@ -0,0 +1,65 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 매장 응답 DTO + * 매장 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 응답") +public class StoreResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "맛있는 카페") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "가게 사진") + private String storeImage; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + private String blogAccounts; + + @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + private String instaAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + private String description; + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java new file mode 100644 index 0000000..1d235b7 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java @@ -0,0 +1,59 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 수정 요청 DTO + * 매장 정보 수정 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 수정 요청") +public class StoreUpdateRequest { + + @Schema(description = "매장명", example = "맛있는 카페") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다") + private String instaAccounts; + + @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String blogAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; +} + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java new file mode 100644 index 0000000..cef5bb6 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java @@ -0,0 +1,90 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 메뉴 엔티티 + * 매장의 메뉴 정보를 관리 + */ +@Entity +@Table(name = "menus") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Menu { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_id") + private Long menuId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "menu_name", nullable = false, length = 100) + private String menuName; + + @Column(name = "category", length = 50) + private String category; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "image_url", length = 500) + private String image; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 메뉴 정보 업데이트 + * + * @param menuName 메뉴명 + * @param category 카테고리 + * @param price 가격 + * @param description 설명 + */ + public void updateMenu(String menuName, String category, Integer price, + String description) { + if (menuName != null && !menuName.trim().isEmpty()) { + this.menuName = menuName; + } + if (category != null && !category.trim().isEmpty()) { + this.category = category; + } + if (price != null && price > 0) { + this.price = price; + } + this.description = description; + } + + /** + * 메뉴 이미지 URL 업데이트 + * + * @param imageUrl 새로운 이미지 URL + */ + public void updateImage(String imageUrl) { + this.image = imageUrl; + this.updatedAt = LocalDateTime.now(); + } + +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java new file mode 100644 index 0000000..91e74c7 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java @@ -0,0 +1,62 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 매출 정보를 나타내는 엔티티 + * 일별 매출 데이터 저장 + */ +@Entity +@Table(name = "sales") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Sales { + + /** + * 매출 고유 식별자 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 매장 ID + */ + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** + * 매출 날짜 + */ + @Column(name = "sales_date", nullable = false) + private LocalDate salesDate; + + /** + * 매출 금액 + */ + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + /** + * 매출 등록 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 엔티티 저장 전 실행되는 메서드 + * 생성 시각을 현재 시각으로 설정 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java new file mode 100644 index 0000000..2c68120 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java @@ -0,0 +1,121 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * 매장 엔티티 + * 매장의 기본 정보와 운영 정보를 관리 + */ +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Store { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id") + private Long id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "store_name", nullable = false, length = 100) + private String storeName; + + @Column(name = "business_type", length = 50) + private String businessType; + + @Column(name = "address", nullable = false, length = 200) + private String address; + + @Column(name = "phone_number", length = 20) + private String phoneNumber; + + @Column(name = "business_hours", length = 100) + private String businessHours; + + @Column(name = "closed_days", length = 100) + private String closedDays; + + @Column(name = "seat_count") + private Integer seatCount; + + @Column(name = "insta_accounts", length = 500) + private String instaAccounts; + + @Column(name = "blog_accounts", length = 500) + private String blogAccounts; + + @Column(name = "description", length = 1000) + private String description; + + @Column(name = "store_image", length = 1000) + private String storeImage; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 매장 정보 업데이트 + * + * @param storeName 매장명 + * @param businessType 업종 + * @param address 주소 + * @param phoneNumber 전화번호 + * @param businessHours 영업시간 + * @param closedDays 휴무일 + * @param seatCount 좌석 수 + * @param instaAccounts SNS 계정 정보 +* @param blogAccounts SNS 계정 정보 + * @param description 설명 + */ + public void updateStore(String storeName, String businessType, String address, + String phoneNumber, String businessHours, String closedDays, + Integer seatCount, String instaAccounts, String blogAccounts, String description) { + if (storeName != null && !storeName.trim().isEmpty()) { + this.storeName = storeName; + } + if (businessType != null && !businessType.trim().isEmpty()) { + this.businessType = businessType; + } + if (address != null && !address.trim().isEmpty()) { + this.address = address; + } + this.phoneNumber = phoneNumber; + this.businessHours = businessHours; + this.closedDays = closedDays; + this.seatCount = seatCount; + this.instaAccounts = instaAccounts; + this.blogAccounts = blogAccounts; + this.description = description; + } + + /** + * 메뉴 이미지 URL 업데이트 + * + * @param imageUrl 새로운 이미지 URL + */ + public void updateImage(String imageUrl) { + this.storeImage = imageUrl; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java new file mode 100644 index 0000000..81d8640 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java @@ -0,0 +1,31 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Menu; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * 메뉴 정보 데이터 접근을 위한 Repository + * JPA를 사용한 메뉴 CRUD 작업 처리 + */ +@Repository +public interface MenuRepository extends JpaRepository { +// /** +// * 전체 메뉴 조회 (메뉴명 오름차순) +// * +// * @return 메뉴 목록 +// */ +// List findAllByOrderByMenuNameAsc(Long ); + + /** + * 매장별 메뉴 조회 + * + * @param storeId 매장 ID + * @return 메뉴 목록 + */ + List findByStoreId(Long storeId); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java new file mode 100644 index 0000000..5fec0c2 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java @@ -0,0 +1,85 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Sales; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 매출 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매출 조회 작업 처리 + */ +@Repository +public interface SalesRepository extends JpaRepository { + + /** + * 매장의 특정 날짜 매출 조회 + * + * @param storeId 매장 ID + * @param salesDate 매출 날짜 + * @return 해당 날짜 매출 목록 + */ + List findByStoreIdAndSalesDate(Long storeId, LocalDate salesDate); + + /** + * 매장의 특정 기간 매출 조회 + * + * @param storeId 매장 ID + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 해당 기간 매출 목록 + */ + List findByStoreIdAndSalesDateBetween(Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * 매장의 오늘 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 오늘 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE", nativeQuery = true) + BigDecimal findTodaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 어제 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 어제 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE - INTERVAL '1 day'", nativeQuery = true) + BigDecimal findYesterdaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 이번 달 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId " + + "AND EXTRACT(YEAR FROM sales_date) = EXTRACT(YEAR FROM CURRENT_DATE) " + + "AND EXTRACT(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true) + BigDecimal findMonthSalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 최근 365일 매출 데이터 조회 (날짜와 함께) + * + * @param storeId 매장 ID + * @return 최근 365일 매출 데이터 (날짜 오름차순) + */ + @Query("SELECT s FROM Sales s " + + "WHERE s.storeId = :storeId " + + "AND s.salesDate >= :startDate " + + "AND s.salesDate <= :endDate " + + "ORDER BY s.salesDate ASC") + List findSalesDataLast365Days( + @Param("storeId") Long storeId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java new file mode 100644 index 0000000..4fbbcea --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java @@ -0,0 +1,42 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 매장 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매장 CRUD 작업 처리 + */ +@Repository +public interface StoreRepository extends JpaRepository { + + /** + * 회원 ID로 매장 조회 + * + * @param userId 회원 ID + * @return 매장 정보 (Optional) + */ + Optional findByUserId(String userId); + + /** + * 회원의 매장 존재 여부 확인 + * + * @param userId 회원 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 매장명으로 매장 조회 + * + * @param storeName 매장명 + * @return 매장 목록 + */ + Optional findByStoreName(String storeName); +} + + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java new file mode 100644 index 0000000..83764a3 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java @@ -0,0 +1,55 @@ +// store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.StoreResponse; +import org.springframework.web.multipart.MultipartFile; + +/** + * Azure Blob Storage 서비스 인터페이스 + * 파일 업로드, 다운로드, 삭제 기능 정의 + */ +public interface BlobStorageService { + + /** + * 이미지 파일 업로드 + * + * @param file 업로드할 파일 + * @param containerName 컨테이너 이름 + * @param fileName 저장할 파일명 + * @return 업로드된 파일의 URL + */ + String uploadImage(MultipartFile file, String containerName, String fileName); + + /** + * 메뉴 이미지 업로드 (편의 메서드) + * + * @param file 업로드할 파일 + * @return 업로드된 파일의 URL + */ + MenuResponse uploadMenuImage(MultipartFile file, Long menuId); + + /** + * 매장 이미지 업로드 (편의 메서드) + * + * @param file 업로드할 파일 + * @param storeId 매장 ID + * @return 업로드된 파일의 URL + */ + StoreResponse uploadStoreImage(MultipartFile file, Long storeId); + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + * @return 삭제 성공 여부 + */ + //boolean deleteFile(String fileUrl); + + /** + * 컨테이너 존재 여부 확인 및 생성 + * + * @param containerName 컨테이너 이름 + */ + void ensureContainerExists(String containerName); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java new file mode 100644 index 0000000..fde8c91 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java @@ -0,0 +1,332 @@ +// store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java +package com.won.smarketing.store.service; + +import com.azure.core.util.BinaryData; +import com.azure.storage.blob.BlobClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.models.BlobHttpHeaders; +import com.azure.storage.blob.models.PublicAccessType; +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.entity.Menu; +import com.won.smarketing.store.entity.Store; +import com.won.smarketing.store.repository.MenuRepository; +import com.won.smarketing.store.repository.StoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/** + * Azure Blob Storage 서비스 구현체 + * 이미지 파일 업로드, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BlobStorageServiceImpl implements BlobStorageService { + + private final BlobServiceClient blobServiceClient; + private final MenuRepository menuRepository; + private final StoreRepository storeRepository; + + @Value("${azure.storage.container.menu-images:menu-images}") + private String menuImageContainer; + + @Value("${azure.storage.container.store-images:store-images}") + private String storeImageContainer; + + @Value("${azure.storage.max-file-size:10485760}") // 10MB + private long maxFileSize; + + // 허용되는 이미지 확장자 + private static final List ALLOWED_EXTENSIONS = Arrays.asList( + "jpg", "jpeg", "png", "gif", "bmp", "webp" + ); + + // 허용되는 MIME 타입 + private static final List ALLOWED_MIME_TYPES = Arrays.asList( + "image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp" + ); + + /** + * 이미지 파일 업로드 + * + * @param file 업로드할 파일 + * @param containerName 컨테이너 이름 + * @param fileName 저장할 파일명 + * @return 업로드된 파일의 URL + */ + @Override + public String uploadImage(MultipartFile file, String containerName, String fileName) { + // 파일 유효성 검증 + validateImageFile(file); + + try { + // 컨테이너 존재 확인 및 생성 + ensureContainerExists(containerName); + + // Blob 클라이언트 생성 + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + BlobClient blobClient = containerClient.getBlobClient(fileName); + + // 파일 업로드 (간단한 방식) + BinaryData binaryData = BinaryData.fromBytes(file.getBytes()); + + // 파일 업로드 실행 (덮어쓰기 허용) + blobClient.upload(binaryData, true); + + // Content-Type 설정 + BlobHttpHeaders headers = new BlobHttpHeaders().setContentType(file.getContentType()); + blobClient.setHttpHeaders(headers); + + String fileUrl = blobClient.getBlobUrl(); + log.info("이미지 업로드 성공: {}", fileUrl); + + return fileUrl; + + } catch (IOException e) { + log.error("이미지 업로드 실패 - 파일 읽기 오류: {}", e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } catch (Exception e) { + log.error("이미지 업로드 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + /** + * 메뉴 이미지 업로드 + * + * @param file 업로드할 파일 + * @return 업로드된 파일의 URL + */ + @Override + public MenuResponse uploadMenuImage(MultipartFile file, Long menuId) { + String fileName = generateMenuImageFileName(file.getOriginalFilename()); + + //메뉴id로 데이터를 찾아서 + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + // 기존 이미지가 있다면 삭제 + if (menu.getImage() != null && !menu.getImage().isEmpty()) { + deleteFile(menu.getImage()); + } + + //새로 올리고 + String fileUrl = uploadImage(file, menuImageContainer, fileName); + + //메뉴에 다시 저장 + menu.updateImage(fileUrl); + menuRepository.save(menu); + + return MenuResponse.builder() + .menuId(menu.getMenuId()) + .menuName(menu.getMenuName()) + .category(menu.getCategory()) + .price(menu.getPrice()) + .image(fileUrl) + .description(menu.getDescription()) + .createdAt(menu.getCreatedAt()) + .updatedAt(menu.getUpdatedAt()) + .build(); + } + + /** + * 매장 이미지 업로드 + * + * @param file 업로드할 파일 + * @param storeId 매장 ID + * @return 업로드된 파일의 URL + */ + @Override + public StoreResponse uploadStoreImage(MultipartFile file, Long storeId) { + String fileName = generateStoreImageFileName(storeId, file.getOriginalFilename()); + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + // 기존 이미지가 있다면 삭제 + if (store.getStoreImage() != null && !store.getStoreImage().isEmpty()) { + deleteFile(store.getStoreImage()); + } + //새로 올리고 + String fileUrl = uploadImage(file, storeImageContainer, fileName); + + store.updateImage(fileUrl); + storeRepository.save(store); + + return StoreResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .businessType(store.getBusinessType()) + .address(store.getAddress()) + .phoneNumber(store.getPhoneNumber()) + .businessHours(store.getBusinessHours()) + .closedDays(store.getClosedDays()) + .seatCount(store.getSeatCount()) + .blogAccounts(store.getBlogAccounts()) + .instaAccounts(store.getInstaAccounts()) + .storeImage(fileUrl) + .description(store.getDescription()) + .createdAt(store.getCreatedAt()) + .updatedAt(store.getUpdatedAt()) + .build(); + } + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + */ +// @Override + public void deleteFile(String fileUrl) { + try { + // URL에서 컨테이너명과 파일명 추출 + String[] urlParts = extractContainerAndFileName(fileUrl); + String containerName = urlParts[0]; + String fileName = urlParts[1]; + + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + BlobClient blobClient = containerClient.getBlobClient(fileName); + + boolean deleted = blobClient.deleteIfExists(); + + if (deleted) { + log.info("파일 삭제 성공: {}", fileUrl); + } else { + log.warn("파일이 존재하지 않음: {}", fileUrl); + } + + } catch (Exception e) { + log.error("파일 삭제 실패: {}", e.getMessage()); + } + } + + /** + * 컨테이너 존재 여부 확인 및 생성 + * + * @param containerName 컨테이너 이름 + */ + @Override + public void ensureContainerExists(String containerName) { + try { + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + + if (!containerClient.exists()) { + containerClient.createWithResponse(null, PublicAccessType.BLOB, null, null); + log.info("컨테이너 생성 완료: {}", containerName); + } + + } catch (Exception e) { + log.error("컨테이너 생성 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.STORAGE_CONTAINER_ERROR); + } + } + + /** + * 이미지 파일 유효성 검증 + * + * @param file 검증할 파일 + */ + private void validateImageFile(MultipartFile file) { + // 파일 존재 여부 확인 + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND); + } + + // 파일 크기 확인 + if (file.getSize() > maxFileSize) { + throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + // 파일 확장자 확인 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new BusinessException(ErrorCode.INVALID_FILE_NAME); + } + + String extension = getFileExtension(originalFilename).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION); + } + + // MIME 타입 확인 + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + } + + /** + * 메뉴 이미지 파일명 생성 + * + * @param originalFilename 원본 파일명 + * @return 생성된 파일명 + */ + private String generateMenuImageFileName(String originalFilename) { + String extension = getFileExtension(originalFilename); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("menu_%s_%s.%s", timestamp, uuid, extension); + } + + /** + * 매장 이미지 파일명 생성 + * + * @param storeId 매장 ID + * @param originalFilename 원본 파일명 + * @return 생성된 파일명 + */ + private String generateStoreImageFileName(Long storeId, String originalFilename) { + String extension = getFileExtension(originalFilename); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("store_%d_%s_%s.%s", storeId, timestamp, uuid, extension); + } + + /** + * 파일 확장자 추출 + * + * @param filename 파일명 + * @return 확장자 + */ + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1) { + return ""; + } + return filename.substring(lastDotIndex + 1); + } + + /** + * URL에서 컨테이너명과 파일명 추출 + * + * @param fileUrl 파일 URL + * @return [컨테이너명, 파일명] 배열 + */ + private String[] extractContainerAndFileName(String fileUrl) { + // URL 형식: https://accountname.blob.core.windows.net/container/filename + try { + String[] parts = fileUrl.split("/"); + String containerName = parts[parts.length - 2]; + String fileName = parts[parts.length - 1]; + return new String[]{containerName, fileName}; + } catch (Exception e) { + throw new BusinessException(ErrorCode.INVALID_FILE_URL); + } + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java new file mode 100644 index 0000000..a2e8963 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java @@ -0,0 +1,57 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 메뉴 서비스 인터페이스 + * 메뉴 관리 관련 비즈니스 로직 정의 + */ +public interface MenuService { + + /** + * 메뉴 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + MenuResponse register(MenuCreateRequest request); + + /** + * 메뉴 목록 조회 + * + * @param storeId 가게 ID + * @return 메뉴 목록 + */ + List getMenus(Long storeId); + + /** + * 메뉴 정보 수정 + * + * @param menuId 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + MenuResponse updateMenu(Long menuId, MenuUpdateRequest request); + + /** + * 메뉴 삭제 + * + * @param menuId 메뉴 ID + */ + void deleteMenu(Long menuId); + +// /** +// * 메뉴 이미지 업로드 +// * +// * @param menuId 메뉴 ID +// * @param file 업로드할 이미지 파일 +// * @return 이미지 업로드 결과 +// */ +// ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java new file mode 100644 index 0000000..d75efc2 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -0,0 +1,166 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import com.won.smarketing.store.entity.Menu; +import com.won.smarketing.store.repository.MenuRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 메뉴 관리 서비스 구현체 + * 메뉴 등록, 조회, 수정, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuServiceImpl implements MenuService { + + private final MenuRepository menuRepository; + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + @Override + @Transactional + public MenuResponse register(MenuCreateRequest request) { + // 메뉴 엔티티 생성 및 저장 + Menu menu = Menu.builder() + .storeId(request.getStoreId()) + .menuName(request.getMenuName()) + .category(request.getCategory()) + .price(request.getPrice()) + .description(request.getDescription()) + .build(); + + Menu savedMenu = menuRepository.save(menu); + return toMenuResponse(savedMenu); + } + + /** + * 메뉴 목록 조회 + * + * @param storeId 가게 ID + * @return 메뉴 목록 + */ + @Override + public List getMenus(Long storeId) { + List menus; + + menus = menuRepository.findByStoreId(storeId); + + return menus.stream() + .map(this::toMenuResponse) + .collect(Collectors.toList()); + } + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + @Override + @Transactional + public MenuResponse updateMenu(Long menuId, MenuUpdateRequest request) { + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + // 메뉴 정보 업데이트 + menu.updateMenu( + request.getMenuName(), + request.getCategory(), + request.getPrice(), + request.getDescription() + ); + + Menu updatedMenu = menuRepository.save(menu); + return toMenuResponse(updatedMenu); + } + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + */ + @Override + @Transactional + public void deleteMenu(Long menuId) { + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + menuRepository.delete(menu); + } + + /** + * Menu 엔티티를 MenuResponse DTO로 변환 + * + * @param menu Menu 엔티티 + * @return MenuResponse DTO + */ + private MenuResponse toMenuResponse(Menu menu) { + return MenuResponse.builder() + .menuId(menu.getMenuId()) + .menuName(menu.getMenuName()) + .category(menu.getCategory()) + .price(menu.getPrice()) + .description(menu.getDescription()) + .createdAt(menu.getCreatedAt()) + .updatedAt(menu.getUpdatedAt()) + .build(); + } + +// /** +// * 메뉴 이미지 업로드 +// * +// * @param menuId 메뉴 ID +// * @param file 업로드할 이미지 파일 +// * @return 이미지 업로드 결과 +// */ +// @Override +// @Transactional +// public ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file) { +// // 메뉴 존재 여부 확인 +// Menu menu = menuRepository.findById(menuId) +// .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); +// +// try { +// // 기존 이미지가 있다면 삭제 +// if (menu.getImage() != null && !menu.getImage().isEmpty()) { +// blobStorageService.deleteFile(menu.getImage()); +// } +// +// // 새 이미지 업로드 +// String imageUrl = blobStorageService.uploadMenuImage(file, menuId); +// +// // 메뉴 엔티티의 이미지 URL 업데이트 +// menu.updateImage(imageUrl); +// menuRepository.save(menu); +// +// return ImageUploadResponse.builder() +// .imageUrl(imageUrl) +// .originalFileName(file.getOriginalFilename()) +// .fileSize(file.getSize()) +// .success(true) +// .message("메뉴 이미지 업로드가 완료되었습니다.") +// .build(); +// +// } catch (Exception e) { +// throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); +// } +// } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java new file mode 100644 index 0000000..5221f56 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java @@ -0,0 +1,17 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; + +/** + * 매출 서비스 인터페이스 + * 매출 조회 관련 비즈니스 로직 정의 + */ +public interface SalesService { + + /** + * 매출 정보 조회 + * + * @return 매출 정보 + */ + SalesResponse getSales(Long storeId); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java new file mode 100644 index 0000000..0fa4848 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java @@ -0,0 +1,100 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.entity.Sales; +import com.won.smarketing.store.repository.SalesRepository; +import com.won.smarketing.store.repository.StoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 매출 관리 서비스 구현체 + * 매출 조회 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SalesServiceImpl implements SalesService { + + private final SalesRepository salesRepository; + + /** + * 매출 정보 조회 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Override + public SalesResponse getSales(Long storeId) { + // 오늘 매출 계산 + BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now()); + + // 이번 달 매출 계산 + BigDecimal monthSales = calculateMonthSales(storeId); + + // 어제 매출 계산 + BigDecimal yesterdaySales = calculateSalesByDate(storeId, LocalDate.now().minusDays(1)); + + // 전일 대비 매출 변화량 계산 + BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales); + + //오늘로부터 1년 전까지의 매출 리스트 + + return SalesResponse.builder() + .todaySales(todaySales) + .monthSales(monthSales) + .yearSales(getSalesAmountListLast365Days(storeId)) + .previousDayComparison(previousDayComparison) + .build(); + } + + /** + * 특정 날짜의 매출 계산 + * + * @param storeId 매장 ID + * @param date 날짜 + * @return 해당 날짜 매출 + */ + private BigDecimal calculateSalesByDate(Long storeId, LocalDate date) { + List salesList = salesRepository.findByStoreIdAndSalesDate(storeId, date); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 이번 달 매출 계산 + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + private BigDecimal calculateMonthSales(Long storeId) { + LocalDate now = LocalDate.now(); + LocalDate startOfMonth = now.withDayOfMonth(1); + LocalDate endOfMonth = now.withDayOfMonth(now.lengthOfMonth()); + + List salesList = salesRepository.findByStoreIdAndSalesDateBetween(storeId, startOfMonth, endOfMonth); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 최근 365일 매출 금액 리스트 조회 + * + * @param storeId 매장 ID + * @return 최근 365일 매출 금액 리스트 + */ + private List getSalesAmountListLast365Days(Long storeId) { + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusDays(365); + + // Sales 엔티티 전체를 조회하는 메서드 사용 + return salesRepository.findSalesDataLast365Days(storeId, startDate, endDate); + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java new file mode 100644 index 0000000..ac72664 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java @@ -0,0 +1,45 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreCreateResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; + +/** + * 매장 서비스 인터페이스 + * 매장 관리 관련 비즈니스 로직 정의 + */ +public interface StoreService { + + /** + * 매장 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + StoreCreateResponse register(StoreCreateRequest request); + + /** + * 매장 정보 조회 (현재 로그인 사용자) + * + * @return 매장 정보 + */ + StoreResponse getMyStore(); + + /** + * 매장 정보 조회 (매장 ID) + * + * //@param userId 매장 ID + * @return 매장 정보 + */ + StoreResponse getStore(); + + /** + * 매장 정보 수정 + * + * //@param storeId 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + StoreResponse updateStore(StoreUpdateRequest request); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java new file mode 100644 index 0000000..d8901c0 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java @@ -0,0 +1,190 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreCreateResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; +import com.won.smarketing.store.entity.Store; +import com.won.smarketing.store.repository.StoreRepository; +import jakarta.xml.bind.annotation.XmlType; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 매장 서비스 구현체 + * 매장 등록, 조회, 수정 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StoreServiceImpl implements StoreService { + + private final StoreRepository storeRepository; + + /** + * 매장 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + @Override + @Transactional + public StoreCreateResponse register(StoreCreateRequest request) { + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 + + log.info("매장 등록 시작: {} (회원: {})", request.getStoreName(), memberId); + + // 회원당 하나의 매장만 등록 가능 + if (storeRepository.existsByUserId(memberId)) { + throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); + } + + // 매장 엔티티 생성 및 저장 + Store store = Store.builder() + .userId(memberId) + .storeName(request.getStoreName()) + .businessType(request.getBusinessType()) + .address(request.getAddress()) + .phoneNumber(request.getPhoneNumber()) + .businessHours(request.getBusinessHours()) + .closedDays(request.getClosedDays()) + .seatCount(request.getSeatCount()) + .blogAccounts(request.getBlogAccounts()) + .instaAccounts(request.getInstaAccounts()) + .description(request.getDescription()) + .build(); + + Store savedStore = storeRepository.save(store); + log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId()); + + return toStoreCreateResponse(savedStore); + } + + /** + * 매장 정보 조회 (현재 로그인 사용자) + * + * @return 매장 정보 + */ + @Override + public StoreResponse getMyStore() { + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); + + Store store = storeRepository.findByUserId(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } + + /** + * 매장 정보 조회 (매장 ID) + * + * //@param storeId 매장 ID + * @return 매장 정보 + */ + @Override + public StoreResponse getStore() { + try { + String userId = getCurrentUserId(); + Store store = storeRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } + + /** + * 매장 정보 수정 + * + * //@param storeId 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + @Override + @Transactional + public StoreResponse updateStore(StoreUpdateRequest request) { + String userId = getCurrentUserId(); + + Store store = storeRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + // 매장 정보 업데이트 + store.updateStore( + request.getStoreName(), + request.getBusinessType(), + request.getAddress(), + request.getPhoneNumber(), + request.getBusinessHours(), + request.getClosedDays(), + request.getSeatCount(), + request.getInstaAccounts(), + request.getBlogAccounts(), + request.getDescription() + ); + + Store updatedStore = storeRepository.save(store); + log.info("매장 정보 수정 완료: {} (ID: {})", updatedStore.getStoreName(), updatedStore.getId()); + + return toStoreResponse(updatedStore); + } + + /** + * Store 엔티티를 StoreResponse DTO로 변환 + * + * @param store Store 엔티티 + * @return StoreResponse DTO + */ + private StoreResponse toStoreResponse(Store store) { + return StoreResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .businessType(store.getBusinessType()) + .address(store.getAddress()) + .phoneNumber(store.getPhoneNumber()) + .businessHours(store.getBusinessHours()) + .closedDays(store.getClosedDays()) + .seatCount(store.getSeatCount()) + .blogAccounts(store.getBlogAccounts()) + .instaAccounts(store.getInstaAccounts()) + .description(store.getDescription()) + .createdAt(store.getCreatedAt()) + .updatedAt(store.getUpdatedAt()) + .build(); + } + + private StoreCreateResponse toStoreCreateResponse(Store store) { + return StoreCreateResponse.builder() + .storeId(store.getId()) +// .storeName(store.getStoreName()) +// .businessType(store.getBusinessType()) +// .address(store.getAddress()) +// .phoneNumber(store.getPhoneNumber()) +// .businessHours(store.getBusinessHours()) +// .closedDays(store.getClosedDays()) +// .seatCount(store.getSeatCount()) +// .snsAccounts(store.getSnsAccounts()) +// .description(store.getDescription()) +// .createdAt(store.getCreatedAt()) +// .updatedAt(store.getUpdatedAt()) + .build(); + } + + /** + * 현재 로그인된 사용자 ID 조회 + * + * @return 사용자 ID + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } +} diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml new file mode 100644 index 0000000..5a50cbc --- /dev/null +++ b/smarketing-java/store/src/main/resources/application.yml @@ -0,0 +1,48 @@ +server: + port: ${SERVER_PORT:8082} + +spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + application: + name: store-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:StoreDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + +logging: + level: + com.won.smarketing.store: ${LOG_LEVEL:DEBUG} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} +# Azure Storage 설정 +azure: + storage: + account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02} + account-key: ${AZURE_STORAGE_ACCOUNT_KEY:} + endpoint: ${AZURE_STORAGE_ENDPOINT:https://stdigitalgarage02.blob.core.windows.net} + container: + menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images} + store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images} + max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB