diff --git a/smarketing-ai/GITOPS_TEST.md b/smarketing-ai/GITOPS_TEST.md new file mode 100644 index 0000000..852b85b --- /dev/null +++ b/smarketing-ai/GITOPS_TEST.md @@ -0,0 +1 @@ +# GitOps Test Thu Jun 19 07:13:03 UTC 2025 diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py index d3c91da..218c44d 100644 --- a/smarketing-ai/app.py +++ b/smarketing-ai/app.py @@ -98,7 +98,7 @@ def create_app(): app.logger.error(traceback.format_exc()) return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500 - @app.route('/api/ai/poster', methods=['GET']) + @app.route('/api/ai/poster', methods=['POST']) def generate_poster_content(): """ 홍보 포스터 생성 API @@ -114,7 +114,7 @@ def create_app(): return jsonify({'error': '요청 데이터가 없습니다.'}), 400 # 필수 필드 검증 - required_fields = ['title', 'category', 'contentType', 'images'] + required_fields = ['title', 'category', 'images'] for field in required_fields: if field not in data: return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400 @@ -140,19 +140,14 @@ def create_app(): 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']: diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index e55f855..8e07c93 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -1,3 +1,5 @@ +// smarketing-backend/smarketing-ai/deployment/Jenkinsfile + def PIPELINE_ID = "${env.BUILD_NUMBER}" def getImageTag() { @@ -11,166 +13,184 @@ podTemplate( 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') + containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true) ], volumes: [ - emptyDirVolume(mountPath: '/run/podman', memory: false), - emptyDirVolume(mountPath: '/root/.azure', memory: false) + emptyDirVolume(mountPath: '/run/podman', memory: false) ] ) { node(PIPELINE_ID) { def props def imageTag = getImageTag() - def manifest = "deploy.yaml" - def namespace + + // Manifest Repository 설정 + def MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git' + def MANIFEST_CREDENTIAL_ID = 'github-credentials-smarketing' - stage("Get Source") { - checkout scm - props = readProperties file: "smarketing-ai/deployment/deploy_env_vars" - namespace = "${props.namespace}" + try { + stage("Get Source") { + checkout scm + + // smarketing-ai 하위에 있는 설정 파일 읽기 + props = readProperties file: "smarketing-ai/deployment/deploy_env_vars" - echo "Registry: ${props.registry}" - echo "Image Org: ${props.image_org}" - echo "Team ID: ${props.teamid}" - } - - stage("Check Changes") { - script { - def changes = sh( - script: "git diff --name-only HEAD~1 HEAD", - returnStdout: true - ).trim() - - echo "Changed files: ${changes}" - - if (!changes.contains("smarketing-ai/")) { - echo "No changes in smarketing-ai, skipping build" - currentBuild.result = 'SUCCESS' - error("Stopping pipeline - no changes detected") - } - - echo "Changes detected in smarketing-ai, proceeding with build" + echo "=== Build Information ===" + echo "Service: smarketing-ai" + echo "Image Tag: ${imageTag}" + echo "Registry: ${props.registry}" + echo "Image Org: ${props.image_org}" + echo "Team ID: ${props.teamid}" } - } - 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("Check Changes") { + script { + def changes = sh( + script: "git diff --name-only HEAD~1 HEAD", + returnStdout: true + ).trim() + + echo "Changed files: ${changes}" + + if (!changes.contains("smarketing-ai/")) { + echo "No changes in smarketing-ai, skipping build" + currentBuild.result = 'SUCCESS' + error("Stopping pipeline - no changes detected") + } + + echo "Changes detected in smarketing-ai, proceeding with build" + } + } + + 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 smarketing-ai/deployment/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') { + script { + // Manifest Repository Clone + withCredentials([usernamePassword( + credentialsId: MANIFEST_CREDENTIAL_ID, + usernameVariable: 'GIT_USERNAME', + passwordVariable: 'GIT_PASSWORD' + )]) { + sh """ + echo "=== Git 설정 ===" + git config --global user.name "Jenkins CI" + git config --global user.email "jenkins@company.com" + + echo "=== Manifest Repository Clone ===" + rm -rf manifest-repo + git clone https://\$GIT_USERNAME:\$GIT_PASSWORD@github.com/won-ktds/smarketing-manifest.git manifest-repo + cd manifest-repo + """ + + def fullImageName = "${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + def deploymentFile = "smarketing-ai/deployments/smarketing-ai/smarketing-ai-deployment.yaml" + + sh """ + cd manifest-repo + + echo "=== smarketing-ai 이미지 태그 업데이트 ===" + if [ -f "${deploymentFile}" ]; then + # 이미지 태그 업데이트 (sed 사용) + sed -i 's|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${fullImageName}|g' "${deploymentFile}" + echo "Updated ${deploymentFile} with new image: ${fullImageName}" + + # 변경사항 확인 + echo "=== 변경된 내용 확인 ===" + grep "image: ${props.registry}/${props.image_org}/smarketing-ai" "${deploymentFile}" || echo "이미지 태그 업데이트 확인 실패" + else + echo "Warning: ${deploymentFile} not found" + fi + """ + + sh """ + cd manifest-repo + + echo "=== Git 변경사항 확인 ===" + git status + git diff + + # 변경사항이 있으면 커밋 및 푸시 + if [ -n "\$(git status --porcelain)" ]; then + git add . + git commit -m "Update smarketing-ai to ${imageTag} - Build ${env.BUILD_NUMBER}" + git push origin main + echo "✅ Successfully updated manifest repository" + else + echo "ℹ️ No changes to commit" + fi + """ + } + } + } + } + + stage('Trigger ArgoCD Sync') { + script { + echo """ +🎯 smarketing-ai CI Pipeline 완료! + +📦 빌드된 이미지: +- ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + +🔄 ArgoCD 동작: +- ArgoCD가 manifest repository 변경사항을 자동으로 감지합니다 +- smarketing-ai Application이 새로운 이미지로 동기화됩니다 +- ArgoCD UI에서 배포 상태를 모니터링하세요 + +🌐 ArgoCD UI: [ArgoCD 접속 URL] +📁 Manifest Repo: ${MANIFEST_REPO} """ } } - } - stage('Build & Push Docker Image') { + // 성공 시 처리 + echo """ +✅ smarketing-ai CI Pipeline 성공! +🏷️ 새로운 이미지 태그: ${imageTag} +🔄 ArgoCD가 자동으로 배포를 시작합니다 + """ + + } catch (Exception e) { + // 실패 시 처리 + echo "❌ smarketing-ai CI Pipeline 실패: ${e.getMessage()}" + throw e + } finally { + // 정리 작업 (항상 실행) 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 smarketing-ai/deployment/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 < smarketing-ai/deployment/${manifest}.template > smarketing-ai/deployment/${manifest} - echo "Generated manifest file:" - cat smarketing-ai/deployment/${manifest} - """ - } - } - - container('azure-cli') { - sh """ - kubectl apply -f smarketing-ai/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 "==========================================" - """ + sh 'podman system prune -f || true' } + sh 'rm -rf manifest-repo || true' } } -} \ No newline at end of file +} diff --git a/smarketing-ai/deployment/Jenkinsfile_backup b/smarketing-ai/deployment/Jenkinsfile_backup new file mode 100644 index 0000000..e55f855 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile_backup @@ -0,0 +1,176 @@ +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: "smarketing-ai/deployment/deploy_env_vars" + namespace = "${props.namespace}" + + echo "Registry: ${props.registry}" + echo "Image Org: ${props.image_org}" + echo "Team ID: ${props.teamid}" + } + + stage("Check Changes") { + script { + def changes = sh( + script: "git diff --name-only HEAD~1 HEAD", + returnStdout: true + ).trim() + + echo "Changed files: ${changes}" + + if (!changes.contains("smarketing-ai/")) { + echo "No changes in smarketing-ai, skipping build" + currentBuild.result = 'SUCCESS' + error("Stopping pipeline - no changes detected") + } + + echo "Changes detected in smarketing-ai, proceeding with build" + } + } + + 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 smarketing-ai/deployment/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 < smarketing-ai/deployment/${manifest}.template > smarketing-ai/deployment/${manifest} + echo "Generated manifest file:" + cat smarketing-ai/deployment/${manifest} + """ + } + } + + container('azure-cli') { + sh """ + kubectl apply -f smarketing-ai/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/deploy.yaml.template b/smarketing-ai/deployment/deploy.yaml.template_backup similarity index 100% rename from smarketing-ai/deployment/deploy.yaml.template rename to smarketing-ai/deployment/deploy.yaml.template_backup diff --git a/smarketing-ai/deployment/deploy_env_vars b/smarketing-ai/deployment/deploy_env_vars index 6f33b33..435a1af 100644 --- a/smarketing-ai/deployment/deploy_env_vars +++ b/smarketing-ai/deployment/deploy_env_vars @@ -24,4 +24,4 @@ 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 +azure_storage_container_name=ai-content diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py index 3f6952d..346e24e 100644 --- a/smarketing-ai/models/request_models.py +++ b/smarketing-ai/models/request_models.py @@ -33,16 +33,23 @@ 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 + store_name: Optional[str] = None + business_type: Optional[str] = None + location: Optional[str] = None + + class Config: + schema_extra = { + "example": { + "store_name": "더블샷 카페", + "business_type": "카페", + "location": "서울시 강남구 역삼동", + } + } # 기존 모델들은 유지 diff --git a/smarketing-ai/models/예시.md b/smarketing-ai/models/예시.md new file mode 100644 index 0000000..e69de29 diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py index c90119c..9dc5f97 100644 --- a/smarketing-ai/services/poster_service.py +++ b/smarketing-ai/services/poster_service.py @@ -73,8 +73,8 @@ class PosterService: # 메인 이미지 분석 main_image_analysis = self._analyze_main_image(main_image_url) - # 포스터 생성 프롬프트 생성 (예시 링크 10개 포함) - prompt = self._create_poster_prompt_v3(request, main_image_analysis) + # 포스터 생성 프롬프트 생성 + prompt = self._create_poster_prompt(request, main_image_analysis) # OpenAI로 이미지 생성 image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1536") @@ -92,7 +92,7 @@ class PosterService: def _analyze_main_image(self, image_url: str) -> Dict[str, Any]: """ - 메인 메뉴 이미지 분석 + 메인 메뉴 이미지 분석 시작 """ temp_files = [] try: @@ -101,7 +101,7 @@ class PosterService: 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) @@ -125,13 +125,27 @@ class PosterService: 'error': str(e) } - def _create_poster_prompt_v3(self, request: PosterContentGetRequest, - main_analysis: Dict[str, Any]) -> str: + def _create_poster_prompt(self, request: PosterContentGetRequest, + main_analysis: Dict[str, Any]) -> str: + """ + 카테고리에 따른 포스터 생성 프롬프트 분기 + """ + if request.category == "음식": + return self._create_food_poster_prompt(request, main_analysis) + elif request.category == "이벤트": + return self._create_event_poster_prompt(request, main_analysis) + else: + # 기본값으로 음식 프롬프트 사용 + return self._create_food_poster_prompt(request, main_analysis) + + def _create_food_poster_prompt(self, request: PosterContentGetRequest, + main_analysis: Dict[str, Any]) -> str: """ 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 7개 포함) """ # 메인 이미지 정보 활용 + main_image = main_analysis.get('url') main_description = main_analysis.get('description', '맛있는 음식') main_colors = main_analysis.get('dominant_colors', []) image_info = main_analysis.get('info', {}) @@ -150,21 +164,16 @@ class PosterService: example_links = "\n".join([f"- {link}" for link in self.example_images]) prompt = f""" - ## 카페 홍보 포스터 디자인 요청 + ## {main_image}를 활용한 홍보 포스터 디자인 요청 ### 📋 기본 정보 - 카테고리: {request.category} - 콘텐츠 타입: {request.contentType} + 메뉴 이미지 : {main_image} 메뉴명: {request.menuName or '없음'} 메뉴 정보: {main_description} - ### 📅 이벤트 기간 - 시작일: {request.startDate or '지금'} - 종료일: {request.endDate or '한정 기간'} - 이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요. - ### 🎨 디자인 요구사항 메인 이미지 처리 + - {main_image}는 변경 없이 그대로 사용해주세요. - 기존 메인 이미지는 변경하지 않고 그대로 유지 - 포스터 전체 크기의 1/3 이하로 배치 - 이미지와 조화로운 작은 장식 이미지 추가 @@ -196,7 +205,78 @@ class PosterService: 톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼 ### 🎯 최종 목표 - 고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작 + 고객들이 이 음식을 먹고싶다 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작 """ return prompt + + + def _create_event_poster_prompt(self, request: PosterContentGetRequest, + main_analysis: Dict[str, Any]) -> str: + """ + 이벤트 카테고리 포스터 생성을 위한 AI 프롬프트 생성 + """ + # 메인 이미지 정보 활용 + main_image = main_analysis.get('url') + 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""" + ## {main_image}를 활용한 이벤트 홍보 포스터 디자인 요청 + 메인 이미지에서 최대한 배경만 변경하는 식으로 활용하기 + + + ### 📋 기본 정보 + 이벤트 이미지 : {main_image} + 메뉴명: {request.menuName or '없음'} + 이벤트명: {request.requirement or '특별 이벤트'} {request.requirement}가 이벤트와 관련이 있다면 활용 + 이벤트 정보: {main_description} + + ### 📅 이벤트 기간 (중요!) + 시작일: {request.startDate or '지금'} + 종료일: {request.endDate or '한정 기간'} + ⚠️ 이벤트 기간은 가장 눈에 띄게 크고 강조하여 표시해주세요! + + ### 🎨 이벤트 특화 디자인 요구사항 + 메인 이미지 처리 + - {main_image}는 변경 없이 그대로 사용해주세요. + - AI가 그린 것 같지 않은, 현실적인 사진으로 사용 + - 이벤트의 핵심 내용을 시각적으로 강조 + - 포스터 전체 크기의 30% 영역에 배치 (텍스트 공간 확보) + - 이벤트 분위기에 맞는 역동적인 배치 + - 크기: {image_orientation} + + 텍스트 요소 (이벤트 전용) + - 이벤트명을 가장 크고 임팩트 있게 표시 + - 둥글둥글한 폰트 활용 + - 이벤트 기간을 박스나 강조 영역으로 처리 + - 메뉴명과 이벤트명만 기입해주고 나머지 텍스트는 기입하지 않음. + - 한글로 작성 필수이고, 절대 한글이 깨지지 않게 만들어주세요. 만약 깨질 것 같다면 그냥 빼주세요. + + 이벤트 포스터만의 특별 요소 + - 이미지에 맞는 임팩트 및 패턴 강조 + - 할인이나 혜택을 강조하는 스티커 효과 + + 색상 가이드 + - {color_description}과 비슷한 톤으로 생성 + + + ### 🎯 최종 목표 + 고객들이 "지금 당장 가서 이 이벤트에 참여해야겠다!"라고 생각하게 만드는 강렬하고 화려한 이벤트 홍보 포스터 제작 + """ + + return prompt diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index 16fd8ba..3e37bb6 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -1380,7 +1380,7 @@ class SnsContentService: 'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'], 'image_placement_strategy': [ '매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기', - '텍스트 2-3문장마다 이미지 배치', + '텍스트 2-3문장마다 입력받은 이미지 배치', '이미지 설명은 간결하고 매력적으로', '마지막에 대표 이미지로 마무리' ] @@ -1560,6 +1560,9 @@ class SnsContentService: if not images: return None + # 🔥 핵심 수정: 실제 이미지 개수 계산 + actual_image_count = len(request.images) if request.images else 0 + # 이미지 타입별 분류 categorized_images = { '매장외관': [], @@ -1603,36 +1606,69 @@ class SnsContentService: } ], 'image_sequence': [], - 'usage_guide': [] + 'usage_guide': [], + 'actual_image_count': actual_image_count # 🔥 실제 이미지 수 추가 } - # 각 섹션에 적절한 이미지 배정 - # 인트로: 매장외관 또는 대표 음식 - 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]) + # 🔥 핵심: 실제 이미지 수에 따라 배치 전략 조정 + if actual_image_count == 1: + # 이미지 1개: 가장 대표적인 위치에 배치 + if categorized_images['음식']: + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'][:1]) + elif categorized_images['매장외관']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1]) + else: + placement_plan['structure'][0]['recommended_images'].extend(images[:1]) - # 매장 정보: 외관 + 인테리어 - placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관']) - placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어']) + elif actual_image_count == 2: + # 이미지 2개: 인트로와 메뉴 소개에 각각 배치 + if categorized_images['매장외관'] and categorized_images['음식']: + placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1]) + placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'][:1]) + else: + placement_plan['structure'][0]['recommended_images'].extend(images[:1]) + placement_plan['structure'][2]['recommended_images'].extend(images[1:2]) - # 메뉴 소개: 메뉴판 + 음식 - placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판']) - placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식']) + elif actual_image_count == 3: + # 이미지 3개: 인트로, 매장 정보, 메뉴 소개에 각각 배치 + placement_plan['structure'][0]['recommended_images'].extend(images[:1]) + placement_plan['structure'][1]['recommended_images'].extend(images[1:2]) + placement_plan['structure'][2]['recommended_images'].extend(images[2:3]) - # 총평: 남은 음식 사진 또는 기타 - 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]) + else: + # 이미지 4개 이상: 기존 로직 유지하되 실제 이미지 수로 제한 + remaining_images = images[:] - # 전체 이미지 순서 생성 + # 인트로: 매장외관 또는 대표 음식 + if categorized_images['매장외관'] and remaining_images: + img = categorized_images['매장외관'][0] + placement_plan['structure'][0]['recommended_images'].append(img) + if img in remaining_images: + remaining_images.remove(img) + elif categorized_images['음식'] and remaining_images: + img = categorized_images['음식'][0] + placement_plan['structure'][0]['recommended_images'].append(img) + if img in remaining_images: + remaining_images.remove(img) + + # 나머지 이미지를 순서대로 배치 + section_index = 1 + for img in remaining_images: + if section_index < len(placement_plan['structure']): + placement_plan['structure'][section_index]['recommended_images'].append(img) + section_index += 1 + else: + break + + # 전체 이미지 순서 생성 (실제 사용될 이미지만) 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['image_sequence'] = placement_plan['image_sequence'][:actual_image_count] + # 사용 가이드 생성 placement_plan['usage_guide'] = [ "📸 이미지 배치 가이드라인:", @@ -1674,6 +1710,15 @@ class SnsContentService: """ category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', []) + # 🔥 핵심 추가: 실제 이미지 개수 계산 + actual_image_count = len(request.images) if request.images else 0 + + # 🔥 핵심 추가: 이미지 태그 사용법에 개수 제한 명시 + image_tag_usage = f"""**이미지 태그 사용법 (반드시 준수):** + - 총 {actual_image_count}개의 이미지만 사용 가능 + - [IMAGE_{actual_image_count}]까지만 사용 + - {actual_image_count}개를 초과하는 [IMAGE_X] 태그는 절대 사용 금지""" + prompt = f""" 당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요. **🍸 가게 정보:** @@ -1688,6 +1733,8 @@ class SnsContentService: - 이벤트: {request.eventName or '특별 이벤트'} - 독자층: {request.target} +{image_tag_usage} + **📱 인스타그램 특화 요구사항:** - 글 구조: {platform_spec['content_structure']} - 최대 길이: {platform_spec['max_length']}자 @@ -1709,9 +1756,20 @@ class SnsContentService: 1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작 2. 이모티콘을 적절히 활용하여 시각적 재미 추가 3. 스토리텔링을 통해 감정적 연결 유도 -4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등) -5. 줄바꿈을 활용하여 가독성 향상 -6. 해시태그는 본문과 자연스럽게 연결되도록 배치 +4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시 +5. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등) +6. 줄바꿈을 활용하여 가독성 향상 +7. 해시태그는 본문과 자연스럽게 연결되도록 배치 + +**⚠️ 중요한 제약사항:** +- 반드시 제공된 {actual_image_count}개의 이미지 개수를 초과하지 마세요 +- [IMAGE_{actual_image_count}]까지만 사용하세요 +- 더 많은 이미지 태그를 사용하면 오류가 발생합니다 + +**이미지 태그 사용법:** +- [IMAGE_1]: 첫 번째 이미지 배치 위치 +- [IMAGE_2]: 두 번째 이미지 배치 위치 +- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 **필수 요구사항:** {request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' @@ -1729,6 +1787,9 @@ class SnsContentService: category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', []) seo_keywords = platform_spec['seo_keywords'] + # 🔥 핵심: 실제 이미지 개수 계산 + actual_image_count = len(request.images) if request.images else 0 + # 이미지 배치 정보 추가 image_placement_info = "" if image_placement_plan: @@ -1777,14 +1838,13 @@ class SnsContentService: 1. 검색자의 궁금증을 해결하는 정보 중심 작성 2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함 3. 개인적인 경험과 솔직한 후기 작성 -4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시 -5. 이미지마다 간단한 설명 문구 추가 -6. 지역 정보와 접근성 정보 포함 +4. 이미지마다 간단한 설명 문구 추가 +5. 지역 정보와 접근성 정보 포함 -**이미지 태그 사용법:** -- [IMAGE_1]: 첫 번째 이미지 배치 위치 -- [IMAGE_2]: 두 번째 이미지 배치 위치 -- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 +**⚠️ 중요한 제약사항:** +- 반드시 제공된 {actual_image_count}개의 이미지 개수를 초과하지 마세요 +- [IMAGE_{actual_image_count}]까지만 사용하세요 +- {actual_image_count}개를 초과하는 [IMAGE_X] 태그는 절대 사용 금지 **필수 요구사항:** {request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' @@ -1792,6 +1852,7 @@ class SnsContentService: 네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. 필수 요구사항을 반드시 참고하여 작성해주세요. 이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요. + """ return prompt @@ -1811,6 +1872,14 @@ class SnsContentService: """ import re + # 🔥 핵심 추가: 실제 이미지 개수 계산 + actual_image_count = len(request.images) if request.images else 0 + + # 🔥 핵심 추가: [IMAGE_X] 패턴 찾기 및 초과 태그 제거 + image_tags = re.findall(r'\[IMAGE_(\d+)\]', content) + found_tag_numbers = [int(tag) for tag in image_tags] + removed_tags = [] + # 해시태그 개수 조정 hashtags = re.findall(r'#[\w가-힣]+', content) if len(hashtags) > 15: @@ -1867,6 +1936,14 @@ class SnsContentService: # 이미지를 콘텐츠 맨 앞에 추가 content = images_html_content + content + # 🔥 핵심 수정: 인스타그램 본문에서 [IMAGE_X] 태그 모두 제거 + import re + content = re.sub(r'\[IMAGE_\d+\]', '', content) + + # 🔥 추가: 태그 제거 후 남은 빈 줄 정리 + content = re.sub(r'\n\s*\n\s*\n', '\n\n', content) # 3개 이상의 연속 줄바꿈을 2개로 + content = re.sub(r'
\s*
\s*
', '

', content) # 3개 이상의 연속
을 2개로 + # 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환 elif request.platform == '네이버 블로그' and image_placement_plan: content = self._replace_image_tags_with_html(content, image_placement_plan, request.images) diff --git a/smarketing-ai/uploads/temp/temp_1de3d733-f3bb-44cf-8b4d-3d1e7c31cc42.jpg b/smarketing-ai/uploads/temp/temp_1de3d733-f3bb-44cf-8b4d-3d1e7c31cc42.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_1de3d733-f3bb-44cf-8b4d-3d1e7c31cc42.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_272b6c7e-161f-4977-b91a-185f85b0a6cf.jpg b/smarketing-ai/uploads/temp/temp_272b6c7e-161f-4977-b91a-185f85b0a6cf.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_272b6c7e-161f-4977-b91a-185f85b0a6cf.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_3c2f489c-9a96-48e2-b180-ff13caaee373.jpg b/smarketing-ai/uploads/temp/temp_3c2f489c-9a96-48e2-b180-ff13caaee373.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_3c2f489c-9a96-48e2-b180-ff13caaee373.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_4815be20-a36b-41f2-bcc8-50bf82f23bd1.jpg b/smarketing-ai/uploads/temp/temp_4815be20-a36b-41f2-bcc8-50bf82f23bd1.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_4815be20-a36b-41f2-bcc8-50bf82f23bd1.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_5a50b9d5-7ae8-4410-bf76-3f114020ff87.jpg b/smarketing-ai/uploads/temp/temp_5a50b9d5-7ae8-4410-bf76-3f114020ff87.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_5a50b9d5-7ae8-4410-bf76-3f114020ff87.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_60ad2f5c-8dd3-46b4-bd4b-5cc54b28a2fa.jpg b/smarketing-ai/uploads/temp/temp_60ad2f5c-8dd3-46b4-bd4b-5cc54b28a2fa.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_60ad2f5c-8dd3-46b4-bd4b-5cc54b28a2fa.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_893f4988-2fe7-4c34-a0f5-bf3f8c1f35fa.jpg b/smarketing-ai/uploads/temp/temp_893f4988-2fe7-4c34-a0f5-bf3f8c1f35fa.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_893f4988-2fe7-4c34-a0f5-bf3f8c1f35fa.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_997c1ddc-b000-46ac-9393-c077c9ee3d41.jpg b/smarketing-ai/uploads/temp/temp_997c1ddc-b000-46ac-9393-c077c9ee3d41.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_997c1ddc-b000-46ac-9393-c077c9ee3d41.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_9fd7f543-96bf-4c0d-a9eb-7092e733a4ba.jpg b/smarketing-ai/uploads/temp/temp_9fd7f543-96bf-4c0d-a9eb-7092e733a4ba.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_9fd7f543-96bf-4c0d-a9eb-7092e733a4ba.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_d126c548-36c7-4a51-8962-01f1d9b70480.jpg b/smarketing-ai/uploads/temp/temp_d126c548-36c7-4a51-8962-01f1d9b70480.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_d126c548-36c7-4a51-8962-01f1d9b70480.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_e42a6e2a-cac7-4400-aa90-11c1651ca5fb.jpg b/smarketing-ai/uploads/temp/temp_e42a6e2a-cac7-4400-aa90-11c1651ca5fb.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_e42a6e2a-cac7-4400-aa90-11c1651ca5fb.jpg differ diff --git a/smarketing-ai/uploads/temp/temp_e63e2a7d-bf42-4c2c-9bcb-609578b6f8b7.jpg b/smarketing-ai/uploads/temp/temp_e63e2a7d-bf42-4c2c-9bcb-609578b6f8b7.jpg new file mode 100644 index 0000000..628c52a Binary files /dev/null and b/smarketing-ai/uploads/temp/temp_e63e2a7d-bf42-4c2c-9bcb-609578b6f8b7.jpg differ diff --git a/smarketing-java/GITOPS_TEST.md b/smarketing-java/GITOPS_TEST.md new file mode 100644 index 0000000..9ac9e6c --- /dev/null +++ b/smarketing-java/GITOPS_TEST.md @@ -0,0 +1 @@ +# GitOps Test Thu Jun 19 05:36:03 UTC 2025 diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/SecurityConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/SecurityConfig.java new file mode 100644 index 0000000..08a3949 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.won.smarketing.recommend.config; + +import com.won.smarketing.common.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +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; + + @Value("${allowed-origins}") + private String allowedOrigins; + /** + * 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/**", "/actuator/**", "/health/**", "/error" + ).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.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); + 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/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index d392c82..3b84c68 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -26,10 +26,10 @@ spring: external: store-service: - base-url: ${STORE_SERVICE_URL:http://localhost:8082} + base-url: ${STORE_SERVICE_URL:http://smarketing.20.249.184.228.nip.io} timeout: ${STORE_SERVICE_TIMEOUT:5000} python-ai-service: - base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001} + base-url: ${PYTHON_AI_SERVICE_URL:http://20.249.113.247:5001} api-key: ${PYTHON_AI_API_KEY:dummy-key} timeout: ${PYTHON_AI_TIMEOUT:30000} @@ -70,4 +70,6 @@ info: app: name: ${APP_NAME:smarketing-recommend} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - recommend" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - recommend" + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} \ No newline at end of file diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index e917ca4..01426b7 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -53,6 +53,15 @@ subprojects { implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-identity:1.11.4' + // Azure Blob Storage 의존성 추가 + implementation 'com.azure:azure-storage-blob:12.25.0' + implementation 'com.azure:azure-identity:1.11.1' + + implementation 'com.fasterxml.jackson.core:jackson-core' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + } tasks.named('test') { diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile index ce96650..821ed02 100644 --- a/smarketing-java/deployment/Jenkinsfile +++ b/smarketing-java/deployment/Jenkinsfile @@ -1,3 +1,5 @@ +// smarketing-backend/smarketing-java/deployment/Jenkinsfile + def PIPELINE_ID = "${env.BUILD_NUMBER}" def getImageTag() { @@ -12,230 +14,233 @@ podTemplate( 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') + containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true) ], 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'] + + // Manifest Repository 설정 + def MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git' + def MANIFEST_CREDENTIAL_ID = 'github-credentials-smarketing' - stage("Get Source") { - checkout scm - - // smarketing-java 하위에 있는 설정 파일 읽기 - props = readProperties file: "smarketing-java/deployment/deploy_env_vars" - namespace = "${props.namespace}" + try { + stage("Get Source") { + checkout scm + + // smarketing-java 하위에 있는 설정 파일 읽기 + props = readProperties file: "smarketing-java/deployment/deploy_env_vars" - echo "=== Build Information ===" - echo "Services: ${services}" - echo "Namespace: ${namespace}" - echo "Image Tag: ${imageTag}" - } - - stage("Check Changes") { - script { - def changes = sh( - script: "git diff --name-only HEAD~1 HEAD", - returnStdout: true - ).trim() - - if (!changes.contains("smarketing-java/")) { - echo "No changes in smarketing-java, skipping build" - currentBuild.result = 'SUCCESS' - error("Stopping pipeline - no changes detected") - } - - echo "Changes detected in smarketing-java, proceeding with build" + echo "=== Build Information ===" + echo "Services: ${services}" + echo "Image Tag: ${imageTag}" + echo "Registry: ${props.registry}" + echo "Image Org: ${props.image_org}" } - } - stage("Setup AKS") { - container('azure-cli') { - withCredentials([azureServicePrincipal('azure-credentials')]) { + stage("Check Changes") { + script { + def changes = sh( + script: "git diff --name-only HEAD~1 HEAD", + returnStdout: true + ).trim() + + if (!changes.contains("smarketing-java/")) { + echo "No changes in smarketing-java, skipping build" + currentBuild.result = 'SUCCESS' + error("Stopping pipeline - no changes detected") + } + + echo "Changes detected in smarketing-java, proceeding with build" + } + } + + stage('Build Applications') { + container('gradle') { 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 "=== smarketing-java 디렉토리로 이동 ===" + cd smarketing-java - echo "=== AKS 인증정보 가져오기 (rg-digitalgarage-02) ===" - az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing + echo "=== gradlew 권한 설정 ===" + chmod +x gradlew - echo "=== 네임스페이스 생성 ===" - kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - + 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 "=== 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} - - echo "=== 현재 연결된 클러스터 확인 ===" - kubectl config current-context + echo "=== 빌드 결과 확인 ===" + find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar' """ } } - } - 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' - )]) { + stage('Build & Push Images') { + container('docker') { sh """ - echo "=== Docker로 ACR 로그인 ===" - echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin + echo "=== Docker 데몬 시작 대기 ===" + timeout 30 sh -c 'until docker info; do sleep 1; done' """ - services.each { service -> - script { - def buildDir = "smarketing-java/${service}" - def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}" + // 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 + """ - echo "Building image for ${service}: ${fullImageName}" + 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('Update Manifest Repository') { + container('git') { + script { + // Manifest Repository Clone + withCredentials([usernamePassword( + credentialsId: MANIFEST_CREDENTIAL_ID, + usernameVariable: 'GIT_USERNAME', + passwordVariable: 'GIT_PASSWORD' + )]) { + sh """ + echo "=== Git 설정 ===" + git config --global user.name "Jenkins CI" + git config --global user.email "jenkins@company.com" + + echo "=== Manifest Repository Clone ===" + rm -rf manifest-repo + git clone https://\$GIT_USERNAME:\$GIT_PASSWORD@github.com/won-ktds/smarketing-manifest.git manifest-repo + cd manifest-repo + """ - // 실제 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 파일을 찾을 수 없습니다" + services.each { service -> + def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}" + def deploymentFile = "smarketing/deployments/${service}/${service}-deployment.yaml" + + sh """ + cd manifest-repo + + echo "=== ${service} 이미지 태그 업데이트 ===" + if [ -f "${deploymentFile}" ]; then + # 이미지 태그 업데이트 (sed 사용) + sed -i 's|image: ${props.registry}/${props.image_org}/${service}:.*|image: ${fullImageName}|g' "${deploymentFile}" + echo "Updated ${deploymentFile} with new image: ${fullImageName}" + + # 변경사항 확인 + echo "=== 변경된 내용 확인 ===" + grep "image: ${props.registry}/${props.image_org}/${service}" "${deploymentFile}" || echo "이미지 태그 업데이트 확인 실패" + else + echo "Warning: ${deploymentFile} not found" + fi + """ } - 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} + cd manifest-repo - echo "Successfully built and pushed: ${fullImageName}" + echo "=== Git 변경사항 확인 ===" + git status + git diff + + # 변경사항이 있으면 커밋 및 푸시 + if [ -n "\$(git status --porcelain)" ]; then + git add . + git commit -m "Update SMarketing services to ${imageTag} - Build ${env.BUILD_NUMBER}" + git push origin main + echo "✅ Successfully updated manifest repository" + else + echo "ℹ️ No changes to commit" + fi """ } } } } - } - 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 + stage('Trigger ArgoCD Sync') { + script { + echo """ +🎯 CI Pipeline 완료! - # 이미지 경로 환경변수 설정 - 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} +📦 빌드된 이미지들: +${services.collect { "- ${props.registry}/${props.image_org}/${it}:${imageTag}" }.join('\n')} - echo "=== Manifest 생성 ===" - envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest} - - echo "=== Generated Manifest File ===" - cat smarketing-java/deployment/${manifest} - echo "===============================" - """ +🔄 ArgoCD 동작: +- ArgoCD가 manifest repository 변경사항을 자동으로 감지합니다 +- 각 서비스별 Application이 새로운 이미지로 동기화됩니다 +- ArgoCD UI에서 배포 상태를 모니터링하세요 + +🌐 ArgoCD UI: [ArgoCD 접속 URL] +📁 Manifest Repo: ${MANIFEST_REPO} + """ + } } - container('azure-cli') { - sh """ - echo "=== 현재 연결된 클러스터 재확인 ===" - kubectl config current-context - kubectl cluster-info | head -3 - - echo "=== PostgreSQL 서비스 확인 ===" - kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요." - - echo "=== Manifest 적용 ===" - kubectl apply -f smarketing-java/deployment/${manifest} + // 성공 시 처리 + echo """ +✅ CI Pipeline 성공! +🏷️ 새로운 이미지 태그: ${imageTag} +🔄 ArgoCD가 자동으로 배포를 시작합니다 + """ - 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 - """ + } catch (Exception e) { + // 실패 시 처리 + echo "❌ CI Pipeline 실패: ${e.getMessage()}" + throw e + } finally { + // 정리 작업 (항상 실행) + container('docker') { + sh 'docker system prune -f || true' } + sh 'rm -rf manifest-repo || true' } } } diff --git a/smarketing-java/deployment/Jenkinsfile.backup b/smarketing-java/deployment/Jenkinsfile.backup new file mode 100644 index 0000000..a84cf56 --- /dev/null +++ b/smarketing-java/deployment/Jenkinsfile.backup @@ -0,0 +1,301 @@ +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}" + echo "Registry: ${props.registry}" + echo "Image Org: ${props.image_org}" + } + + stage("Check Changes") { + script { + def changes = sh( + script: "git diff --name-only HEAD~1 HEAD", + returnStdout: true + ).trim() + + if (!changes.contains("smarketing-java/")) { + echo "No changes in smarketing-java, skipping build" + currentBuild.result = 'SUCCESS' + error("Stopping pipeline - no changes detected") + } + + echo "Changes detected in smarketing-java, proceeding with build" + } + } + + 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 인증정보 가져오기 (rg-digitalgarage-02) ===" + az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --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} + + echo "=== 현재 연결된 클러스터 확인 ===" + kubectl config current-context + """ + } + } + } + + 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} + + # PostgreSQL 환경변수 추가 (올바른 DB명으로 수정) + export postgres_host='${props.POSTGRES_HOST}' + export postgres_port='5432' + export postgres_db_member='MemberDB' + export postgres_db_store='StoreDB' + export postgres_db_marketing_content='MarketingContentDB' + export postgres_db_ai_recommend='AiRecommendationDB' + + # Redis 환경변수 추가 + export redis_host='${props.REDIS_HOST}' + export redis_port='6380' + export redis_password='${props.REDIS_PASSWORD}' + + # 리소스 요구사항 + 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 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 "=== 환경변수 확인 ===" + echo "namespace: \$namespace" + echo "postgres_host: \$postgres_host" + echo "postgres_port: \$postgres_port" + echo "postgres_user: \$postgres_user" + echo "postgres_db_member: \$postgres_db_member" + echo "postgres_db_store: \$postgres_db_store" + echo "postgres_db_marketing_content: \$postgres_db_marketing_content" + echo "postgres_db_ai_recommend: \$postgres_db_ai_recommend" + echo "redis_host: \$redis_host" + echo "redis_port: \$redis_port" + echo "replicas: \$replicas" + + 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 "=== 현재 연결된 클러스터 재확인 ===" + kubectl config current-context + kubectl cluster-info | head -3 + + echo "=== 기존 ConfigMap 삭제 (타입 충돌 해결) ===" + kubectl delete configmap member-config store-config marketing-content-config ai-recommend-config -n ${namespace} --ignore-not-found=true + + echo "=== PostgreSQL 서비스 확인 ===" + kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스를 찾을 수 없습니다." + + echo "=== Redis 서비스 확인 ===" + kubectl get svc -n ${namespace} | grep redis || echo "Redis 서비스를 찾을 수 없습니다." + + echo "=== Manifest 적용 ===" + kubectl apply -f smarketing-java/deployment/${manifest} + + echo "=== 배포 상태 확인 (30초 대기) ===" + sleep 30 + kubectl -n ${namespace} get deployments + kubectl -n ${namespace} get pods + + echo "=== ConfigMap 확인 ===" + kubectl -n ${namespace} get configmap member-config -o yaml | grep -A 10 "data:" + kubectl -n ${namespace} get configmap ai-recommend-config -o yaml | grep -A 10 "data:" + + echo "=== Secret 확인 ===" + kubectl -n ${namespace} get secret member-secret -o yaml | grep -A 5 "data:" + + echo "=== 각 서비스 배포 대기 (120초 timeout) ===" + timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=120s || echo "member deployment 대기 타임아웃" + timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=120s || echo "store deployment 대기 타임아웃" + timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=120s || echo "marketing-content deployment 대기 타임아웃" + timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=120s || echo "ai-recommend deployment 대기 타임아웃" + + echo "=== 최종 배포 상태 ===" + kubectl -n ${namespace} get all + + echo "=== 각 서비스 Pod 로그 확인 (최근 20라인) ===" + for service in member store marketing-content ai-recommend; do + echo "=== \$service 서비스 로그 ===" + kubectl -n ${namespace} logs deployment/\$service --tail=20 || echo "\$service 로그를 가져올 수 없습니다" + echo "" + done + + 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 -30 + echo "=== Pod 로그: \$pod ===" + kubectl -n ${namespace} logs \$pod --tail=50 || echo "로그를 가져올 수 없습니다" + echo "==========================================" + fi + done + + echo "=== Ingress 상태 확인 ===" + kubectl -n ${namespace} get ingress + kubectl -n ${namespace} describe ingress smarketing-backend || echo "Ingress를 찾을 수 없습니다" + + echo "=== 서비스 Endpoint 확인 ===" + kubectl -n ${namespace} get endpoints + """ + } + } + } +} diff --git a/smarketing-java/deployment/argocd.yaml b/smarketing-java/deployment/argocd.yaml new file mode 100644 index 0000000..21a6122 --- /dev/null +++ b/smarketing-java/deployment/argocd.yaml @@ -0,0 +1,28 @@ +## Globally shared configuration +global: + # -- Default domain used by all components + ## Used for ingresses, certificates, SSO, notifications, etc. + ## IP는 외부에서 접근할 수 있는 ks8 node의 Public IP 또는 + ## ingress-nginx-controller 서비스의 External IP이여야 함 + domain: argo.20.249.184.228.nip.io + + # -- 특정 노드에 배포시 지정 + #nodeSelector: + #agentpool: argocd + +server: + ingress: + enabled: true + https: true + annotations: + kubernetes.io/ingress.class: nginx + tls: + - secretName: argocd-tls-smarketing-secret + extraArgs: + - --insecure # ArgoCD 서버가 TLS 종료를 Ingress에 위임 + +configs: + params: + server.insecure: true # Ingress에서 TLS를 처리하므로 ArgoCD 서버는 HTTP로 통신 +certificate: + enabled: false # 자체 서명 인증서 사용 비활성화 (외부 인증서 사용 시) diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template.backup similarity index 63% rename from smarketing-java/deployment/deploy.yaml.template rename to smarketing-java/deployment/deploy.yaml.template.backup index 92e1068..1f54a76 100644 --- a/smarketing-java/deployment/deploy.yaml.template +++ b/smarketing-java/deployment/deploy.yaml.template.backup @@ -8,16 +8,11 @@ data: ALLOWED_ORIGINS: ${allowed_origins} JPA_DDL_AUTO: update JPA_SHOW_SQL: 'true' - # 🔧 강화된 Actuator 설정 + # Actuator 설정 MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: '*' MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always MANAGEMENT_ENDPOINT_HEALTH_ENABLED: 'true' MANAGEMENT_ENDPOINTS_WEB_BASE_PATH: /actuator - MANAGEMENT_SERVER_PORT: '8080' - # Spring Security 비활성화 (Actuator용) - SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration - # 또는 Management port를 main port와 동일하게 - MANAGEMENT_SERVER_PORT: '' --- apiVersion: v1 @@ -26,10 +21,14 @@ metadata: name: member-config namespace: ${namespace} data: - POSTGRES_DB: member - POSTGRES_HOST: member-postgresql - POSTGRES_PORT: '5432' SERVER_PORT: '8081' + POSTGRES_HOST: ${postgres_host} + POSTGRES_PORT: '5432' + POSTGRES_DB: ${postgres_db_member} + REDIS_HOST: ${redis_host} + REDIS_PORT: '6380' + JPA_DDL_AUTO: 'create-drop' + JPA_SHOW_SQL: 'true' --- apiVersion: v1 @@ -38,10 +37,14 @@ metadata: name: store-config namespace: ${namespace} data: - POSTGRES_DB: store - POSTGRES_HOST: store-postgresql - POSTGRES_PORT: '5432' SERVER_PORT: '8082' + POSTGRES_HOST: ${postgres_host} + POSTGRES_PORT: '5432' + POSTGRES_DB: ${postgres_db_store} + REDIS_HOST: ${redis_host} + REDIS_PORT: '6380' + JPA_DDL_AUTO: 'create-drop' + JPA_SHOW_SQL: 'true' --- apiVersion: v1 @@ -50,10 +53,14 @@ metadata: name: marketing-content-config namespace: ${namespace} data: - POSTGRES_DB: marketing_content - POSTGRES_HOST: marketing-content-postgresql - POSTGRES_PORT: '5432' SERVER_PORT: '8083' + POSTGRES_HOST: ${postgres_host} + POSTGRES_PORT: '5432' + POSTGRES_DB: ${postgres_db_marketing_content} + REDIS_HOST: ${redis_host} + REDIS_PORT: '6380' + JPA_DDL_AUTO: 'create-drop' + JPA_SHOW_SQL: 'true' --- apiVersion: v1 @@ -62,10 +69,14 @@ metadata: name: ai-recommend-config namespace: ${namespace} data: - POSTGRES_DB: ai_recommend - POSTGRES_HOST: ai-recommend-postgresql - POSTGRES_PORT: '5432' SERVER_PORT: '8084' + POSTGRES_HOST: ${postgres_host} + POSTGRES_PORT: '5432' + POSTGRES_DB: ${postgres_db_ai_recommend} + REDIS_HOST: ${redis_host} + REDIS_PORT: '6380' + JPA_DDL_AUTO: 'create-drop' + JPA_SHOW_SQL: 'true' --- # Secrets @@ -87,8 +98,9 @@ metadata: stringData: JWT_ACCESS_TOKEN_VALIDITY: '3600000' JWT_REFRESH_TOKEN_VALIDITY: '86400000' - POSTGRES_PASSWORD: ${postgres_password} POSTGRES_USER: ${postgres_user} + POSTGRES_PASSWORD: ${postgres_password} + REDIS_PASSWORD: ${redis_password} type: Opaque --- @@ -98,8 +110,9 @@ metadata: name: store-secret namespace: ${namespace} stringData: - POSTGRES_PASSWORD: ${postgres_password} POSTGRES_USER: ${postgres_user} + POSTGRES_PASSWORD: ${postgres_password} + REDIS_PASSWORD: ${redis_password} type: Opaque --- @@ -109,8 +122,9 @@ metadata: name: marketing-content-secret namespace: ${namespace} stringData: - POSTGRES_PASSWORD: ${postgres_password} POSTGRES_USER: ${postgres_user} + POSTGRES_PASSWORD: ${postgres_password} + REDIS_PASSWORD: ${redis_password} type: Opaque --- @@ -120,8 +134,9 @@ metadata: name: ai-recommend-secret namespace: ${namespace} stringData: - POSTGRES_PASSWORD: ${postgres_password} POSTGRES_USER: ${postgres_user} + POSTGRES_PASSWORD: ${postgres_password} + REDIS_PASSWORD: ${redis_password} type: Opaque --- @@ -167,39 +182,6 @@ spec: 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 - # 🔧 개선된 Health Check 설정 - livenessProbe: - httpGet: - path: /actuator/health - port: 8081 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 120 # 2분으로 증가 - periodSeconds: 30 - timeoutSeconds: 10 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: 8081 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 60 # 1분으로 증가 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 --- apiVersion: apps/v1 @@ -243,38 +225,7 @@ spec: 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 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 120 - periodSeconds: 30 - timeoutSeconds: 10 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: 8082 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 60 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 + --- apiVersion: apps/v1 @@ -318,38 +269,7 @@ spec: 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 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 120 - periodSeconds: 30 - timeoutSeconds: 10 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: 8083 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 60 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 + --- apiVersion: apps/v1 @@ -393,38 +313,7 @@ spec: 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 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 120 - periodSeconds: 30 - timeoutSeconds: 10 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /actuator/health/readiness - port: 8084 - httpHeaders: - - name: Accept - value: application/json - initialDelaySeconds: 60 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 + --- # Services @@ -487,14 +376,15 @@ spec: apiVersion: networking.k8s.io/v1 kind: Ingress metadata: - name: smarketing-backend + name: smarketing-ingress namespace: ${namespace} annotations: kubernetes.io/ingress.class: nginx spec: ingressClassName: nginx rules: - - http: + - host: smarketing.20.249.184.228.nip.io + http: paths: - path: /api/auth pathType: Prefix @@ -524,3 +414,4 @@ spec: name: ai-recommend port: number: 80 + diff --git a/smarketing-java/deployment/deploy_env_vars b/smarketing-java/deployment/deploy_env_vars index 7a5d327..5e90919 100644 --- a/smarketing-java/deployment/deploy_env_vars +++ b/smarketing-java/deployment/deploy_env_vars @@ -8,8 +8,9 @@ registry=acrdigitalgarage02.azurecr.io image_org=smarketing # Application Settings +ingress_host=smarketing.20.249.184.228.nip.io replicas=1 -allowed_origins=http://20.249.171.38 +allowed_origins=http://20.249.154.194 # Security Settings jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ diff --git a/smarketing-java/deployment/member/member-deployment.yaml b/smarketing-java/deployment/member/member-deployment.yaml new file mode 100644 index 0000000..8a933c1 --- /dev/null +++ b/smarketing-java/deployment/member/member-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: member + namespace: smarketing +spec: + replicas: 2 + selector: + matchLabels: + app: member + template: + metadata: + labels: + app: member + spec: + containers: + - name: member + image: acrdigitalgarage02.azurecr.io/member:latest + ports: + - containerPort: 8080 + env: + - name: SPRING_PROFILES_ACTIVE + value: "k8s" + resources: + requests: + memory: "256Mi" + cpu: "256m" + limits: + memory: "1024Mi" + cpu: "1024m" diff --git a/smarketing-java/deployment/member/member-service.yaml b/smarketing-java/deployment/member/member-service.yaml new file mode 100644 index 0000000..8d8e892 --- /dev/null +++ b/smarketing-java/deployment/member/member-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: member + namespace: smarketing +spec: + selector: + app: member + ports: + - port: 80 + targetPort: 8080 + type: ClusterIP diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle index 715bc47..188d7bd 100644 --- a/smarketing-java/marketing-content/build.gradle +++ b/smarketing-java/marketing-content/build.gradle @@ -1,7 +1,4 @@ dependencies { implementation project(':common') runtimeOnly 'org.postgresql:postgresql' - - // WebClient를 위한 Spring WebFlux 의존성 - implementation 'org.springframework.boot:spring-boot-starter-webflux' } \ No newline at end of file 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 index a676c40..94c894d 100644 --- 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 @@ -6,30 +6,41 @@ 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.model.store.StoreWithMenuData; import com.won.smarketing.content.domain.repository.ContentRepository; import com.won.smarketing.content.domain.service.AiPosterGenerator; +import com.won.smarketing.content.domain.service.BlobStorageService; +import com.won.smarketing.content.domain.service.StoreDataProvider; 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 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.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; +import java.util.List; /** * 포스터 콘텐츠 서비스 구현체 * 홍보 포스터 생성 및 저장 기능 구현 */ @Service +@Slf4j @RequiredArgsConstructor @Transactional(readOnly = true) public class PosterContentService implements PosterContentUseCase { + @Value("${azure.storage.container.poster-images:poster-images}") + private String posterImageContainer; + private final ContentRepository contentRepository; private final AiPosterGenerator aiPosterGenerator; + private final BlobStorageService blobStorageService; + private final StoreDataProvider storeDataProvider; /** * 포스터 콘텐츠 생성 @@ -39,26 +50,24 @@ public class PosterContentService implements PosterContentUseCase { */ @Override @Transactional - public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { + public PosterContentCreateResponse generatePosterContent(List images, PosterContentCreateRequest request) { - String generatedPoster = aiPosterGenerator.generatePoster(request); + // 1. 이미지 blob storage에 저장하고 request 저장 + List imageUrls = blobStorageService.uploadImage(images, posterImageContainer); + request.setImages(imageUrls); + + // 매장 정보 호출 + String userId = getCurrentUserId(); + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId); - // 생성 조건 정보 구성 - CreationConditions conditions = CreationConditions.builder() - .category(request.getCategory()) - .requirement(request.getRequirement()) - .eventName(request.getEventName()) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) - .photoStyle(request.getPhotoStyle()) - .build(); + // 2. AI 요청 + String generatedPoster = aiPosterGenerator.generatePoster(request, storeWithMenuData); return PosterContentCreateResponse.builder() .contentId(null) // 임시 생성이므로 ID 없음 .contentType(ContentType.POSTER.name()) .title(request.getTitle()) - .posterImage(generatedPoster) - .posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함) + .content(generatedPoster) .status(ContentStatus.DRAFT.name()) .build(); } @@ -68,7 +77,6 @@ public class PosterContentService implements PosterContentUseCase { * * @param request 포스터 콘텐츠 저장 요청 */ - @Override @Transactional public void savePosterContent(PosterContentSaveRequest request) { // 생성 조건 구성 @@ -96,4 +104,11 @@ public class PosterContentService implements PosterContentUseCase { // 저장 contentRepository.save(content); } + + /** + * 현재 로그인된 사용자 ID 조회 + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } } \ No newline at end of file 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 index 753063b..2aa51d0 100644 --- 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 @@ -14,6 +14,7 @@ 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.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -34,6 +35,9 @@ public class SnsContentService implements SnsContentUseCase { private final AiContentGenerator aiContentGenerator; private final BlobStorageService blobStorageService; + @Value("${azure.storage.container.poster-images:content-images}") + private String contentImageContainer; + /** * SNS 콘텐츠 생성 * @@ -44,8 +48,10 @@ public class SnsContentService implements SnsContentUseCase { @Transactional public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List files) { //파일들 주소 가져옴 - List urls = blobStorageService.uploadImage(files); - request.setImages(urls); + if(files != null) { + List urls = blobStorageService.uploadImage(files, contentImageContainer); + request.setImages(urls); + } // AI를 사용하여 SNS 콘텐츠 생성 String content = aiContentGenerator.generateSnsContent(request); 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 index 6bf2960..77a7496 100644 --- 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 @@ -1,9 +1,13 @@ // 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.domain.model.Content; 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 org.springframework.web.multipart.MultipartFile; + +import java.util.List; /** * 포스터 콘텐츠 관련 UseCase 인터페이스 @@ -16,7 +20,7 @@ public interface PosterContentUseCase { * @param request 포스터 콘텐츠 생성 요청 * @return 포스터 콘텐츠 생성 응답 */ - PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); + PosterContentCreateResponse generatePosterContent(List images, PosterContentCreateRequest request); /** * 포스터 콘텐츠 저장 diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/SecurityConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/SecurityConfig.java new file mode 100644 index 0000000..ca74fc1 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.won.smarketing.content.config; + +import com.won.smarketing.common.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +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; + + @Value("${allowed-origins}") + private String allowedOrigins; + /** + * 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/**", "/actuator/**", "/health/**", "/error" + ).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.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); + 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/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java index 72e1a78..8fb41fc 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java @@ -1,4 +1,3 @@ -// marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java package com.won.smarketing.content.config; import org.springframework.context.annotation.Bean; @@ -20,8 +19,8 @@ public class WebClientConfig { @Bean public WebClient webClient() { HttpClient httpClient = HttpClient.create() - .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 50000) - .responseTimeout(Duration.ofMillis(300000)); + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 15000) // 연결 타임아웃: 15초 + .responseTimeout(Duration.ofMinutes(5)); // 응답 타임아웃: 5분 return WebClient.builder() .clientConnector(new ReactorClientHttpConnector(httpClient)) 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 index 32b4231..1a453ef 100644 --- 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 @@ -27,42 +27,37 @@ import java.util.List; @Builder public class Content { - // ==================== 기본키 및 식별자 ==================== @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "content_id") 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) { 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 index a284c2c..b90959e 100644 --- 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 @@ -24,8 +24,6 @@ public class CreationConditions { private String id; private String category; private String requirement; -// private String toneAndManner; -// private String emotionIntensity; private String storeName; private String storeType; private String target; diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/MenuData.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/MenuData.java new file mode 100644 index 0000000..d6597ad --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/MenuData.java @@ -0,0 +1,21 @@ +package com.won.smarketing.content.domain.model.store; + +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/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreData.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreData.java new file mode 100644 index 0000000..8ae13f4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreData.java @@ -0,0 +1,22 @@ +package com.won.smarketing.content.domain.model.store; + +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/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreWithMenuData.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreWithMenuData.java new file mode 100644 index 0000000..962969b --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/store/StoreWithMenuData.java @@ -0,0 +1,13 @@ +package com.won.smarketing.content.domain.model.store; + +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/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 index a689d30..c550b6c 100644 --- 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 @@ -1,5 +1,6 @@ package com.won.smarketing.content.domain.service; +import com.won.smarketing.content.domain.model.store.StoreWithMenuData; import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import java.util.Map; @@ -16,5 +17,5 @@ public interface AiPosterGenerator { * @param request 포스터 생성 요청 * @return 생성된 포스터 이미지 URL */ - String generatePoster(PosterContentCreateRequest request); + String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData); } diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java index 76ea929..92a6daf 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageService.java @@ -17,7 +17,7 @@ public interface BlobStorageService { * @param file 업로드할 파일 * @return 업로드된 파일의 URL */ - List uploadImage(List file); + List uploadImage(List file, String containerName); /** diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java index 3bb63f1..c9b9d40 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/BlobStorageServiceImpl.java @@ -34,12 +34,6 @@ public class BlobStorageServiceImpl implements BlobStorageService { private final BlobServiceClient blobServiceClient; - @Value("${azure.storage.container.poster-images:poster-images}") - private String posterImageContainer; - - @Value("${azure.storage.container.content-images:content-images}") - private String contentImageContainer; - @Value("${azure.storage.max-file-size:10485760}") // 10MB private long maxFileSize; @@ -60,7 +54,7 @@ public class BlobStorageServiceImpl implements BlobStorageService { * @return 업로드된 파일의 URL */ @Override - public List uploadImage(List files) { + public List uploadImage(List files, String containerName) { // 파일 유효성 검증 validateImageFile(files); List urls = new ArrayList<>(); @@ -70,10 +64,10 @@ public class BlobStorageServiceImpl implements BlobStorageService { for(MultipartFile file : files) { String fileName = generateMenuImageFileName(file.getOriginalFilename()); - ensureContainerExists(posterImageContainer); + ensureContainerExists(containerName); // Blob 클라이언트 생성 - BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(posterImageContainer); + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); BlobClient blobClient = containerClient.getBlobClient(fileName); // 파일 업로드 (간단한 방식) @@ -158,12 +152,12 @@ public class BlobStorageServiceImpl implements BlobStorageService { * @param files 검증할 파일 */ private void validateImageFile(List files) { - for (MultipartFile file : files) { - // 파일 존재 여부 확인 - if (file == null || file.isEmpty()) { - throw new BusinessException(ErrorCode.FILE_NOT_FOUND); - } + // 파일 존재 여부 확인 + if (files == null || files.isEmpty()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND); + } + for (MultipartFile file : files) { // 파일 크기 확인 if (file.getSize() > maxFileSize) { throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED); diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/StoreDataProvider.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/StoreDataProvider.java new file mode 100644 index 0000000..c28d33a --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/StoreDataProvider.java @@ -0,0 +1,11 @@ +package com.won.smarketing.content.domain.service; + +import com.won.smarketing.content.domain.model.store.StoreWithMenuData; + +/** + * 매장 데이터 제공 도메인 서비스 인터페이스 + */ +public interface StoreDataProvider { + + StoreWithMenuData getStoreWithMenuData(String userId); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java index 1fc2020..9227d85 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java @@ -1,5 +1,8 @@ package com.won.smarketing.content.infrastructure.external; +import com.won.smarketing.content.domain.model.store.MenuData; +import com.won.smarketing.content.domain.model.store.StoreData; +import com.won.smarketing.content.domain.model.store.StoreWithMenuData; import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import lombok.RequiredArgsConstructor; @@ -11,7 +14,9 @@ import org.springframework.web.reactive.function.client.WebClient; import java.time.Duration; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * Claude AI를 활용한 포스터 생성 구현체 @@ -34,12 +39,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator { * @return 생성된 포스터 이미지 URL */ @Override - public String generatePoster(PosterContentCreateRequest request) { + public String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) { try { log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl); // 요청 데이터 구성 - Map requestBody = buildRequestBody(request); + Map requestBody = buildRequestBody(request, storeWithMenuData); log.debug("포스터 생성 요청 데이터: {}", requestBody); @@ -51,7 +56,7 @@ public class PythonAiPosterGenerator implements AiPosterGenerator { .bodyValue(requestBody) .retrieve() .bodyToMono(Map.class) - .timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음 + .timeout(Duration.ofSeconds(90)) .block(); // 응답에서 content(이미지 URL) 추출 @@ -75,9 +80,32 @@ public class PythonAiPosterGenerator implements AiPosterGenerator { * Python 서비스의 PosterContentGetRequest 모델에 맞춤 * 카테고리, */ - private Map buildRequestBody(PosterContentCreateRequest request) { + private Map buildRequestBody(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) { Map requestBody = new HashMap<>(); +// TODO : 매장 정보 호출 후 request + +// StoreData storeData = storeWithMenuData.getStoreData(); +// List menuDataList = storeWithMenuData.getMenuDataList(); +// +// 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()); +// +// requestBody.put("store_name", storeData.getStoreName()); +// requestBody.put("business_type", storeData.getBusinessType()); +// requestBody.put("location", storeData.getLocation()); +// requestBody.put("seat_count", storeData.getSeatCount()); +// requestBody.put("menu_list", menuList); + // 기본 정보 requestBody.put("title", request.getTitle()); requestBody.put("category", request.getCategory()); diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/StoreApiDataProvider.java new file mode 100644 index 0000000..8480161 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/StoreApiDataProvider.java @@ -0,0 +1,310 @@ +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.content.domain.model.store.MenuData; +import com.won.smarketing.content.domain.model.store.StoreData; +import com.won.smarketing.content.domain.model.store.StoreWithMenuData; +import com.won.smarketing.content.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.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/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 index fd40ffd..bb4bfc6 100644 --- 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 @@ -9,9 +9,10 @@ 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.media.Content; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -26,17 +27,16 @@ import java.util.List; * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 */ @Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") +@Slf4j @RestController @RequestMapping("/api/content") @RequiredArgsConstructor public class ContentController { - @Autowired - private ObjectMapper objectMapper; - private final SnsContentUseCase snsContentUseCase; private final PosterContentUseCase posterContentUseCase; private final ContentQueryUseCase contentQueryUseCase; + private final ObjectMapper objectMapper; /** * SNS 게시물 생성 @@ -46,7 +46,7 @@ public class ContentController { @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") @PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> generateSnsContent(@Valid @RequestPart("request") String requestJson, - @Valid @RequestPart("files") List images) throws JsonProcessingException { + @Valid @RequestPart(name = "files", required = false) List images) throws JsonProcessingException { SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class); SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images); return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); @@ -72,15 +72,22 @@ public class ContentController { * @return 생성된 포스터 콘텐츠 정보 */ @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") - @PostMapping("/poster/generate") - public ResponseEntity> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { - PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); + @PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> generatePosterContent( + @Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "images", required = false) List images, + @RequestPart("request") String requestJson) throws JsonProcessingException { + + // JSON 파싱 + PosterContentCreateRequest request = objectMapper.readValue(requestJson, PosterContentCreateRequest.class); + + PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(images, request); return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); } /** * 홍보 포스터 저장 - * + * * @param request 포스터 콘텐츠 저장 요청 * @return 저장 성공 응답 */ 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 index 1cbf87d..65508ce 100644 --- 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 @@ -50,9 +50,7 @@ public class PosterContentCreateRequest { @Schema(description = "이미지 스타일", example = "모던") private String imageStyle; - @Schema(description = "업로드된 이미지 URL 목록", required = true) - @NotNull(message = "이미지는 1개 이상 필수입니다") - @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + @Schema(description = "업로드된 이미지 URL 목록") private List images; @Schema(description = "콘텐츠 카테고리", example = "이벤트") 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 index 0c02b68..5fa5c53 100644 --- 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 @@ -31,19 +31,9 @@ public class PosterContentCreateResponse { @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 index 9cdf9e1..e05612c 100644 --- 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 @@ -1,8 +1,6 @@ -// 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; @@ -19,12 +17,7 @@ import java.util.List; @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는 필수입니다") + @Schema(description = "매장 ID", example = "1") private Long storeId; @Schema(description = "제목", example = "특별 이벤트 안내") @@ -36,22 +29,12 @@ public class PosterContentSaveRequest { @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; 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 index f8bcdeb..271d604 100644 --- 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 @@ -68,18 +68,6 @@ public class SnsContentCreateRequest { @Schema(description = "콘텐츠 타입", example = "SNS 게시물") private String contentType; -// @Schema(description = "톤앤매너", -// example = "친근함", -// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) -// private String toneAndManner; - -// @Schema(description = "감정 강도", -// example = "보통", -// allowableValues = {"약함", "보통", "강함"}) -// private String emotionIntensity; - - // ==================== 이벤트 정보 ==================== - @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", example = "신메뉴 출시 이벤트") @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 819d127..bab1983 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -37,6 +37,10 @@ logging: external: ai-service: base-url: ${AI_SERVICE_BASE_URL:http://20.249.113.247:5001} + store-service: + base-url: ${STORE_SERVICE_URL:http://smarketing.20.249.184.228.nip.io} + timeout: ${STORE_SERVICE_TIMEOUT:5000} + azure: storage: account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02} @@ -67,4 +71,7 @@ info: app: name: ${APP_NAME:smarketing-content} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - content" + + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} \ No newline at end of file diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/SecurityConfig.java similarity index 88% rename from smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java rename to smarketing-java/member/src/main/java/com/won/smarketing/member/config/SecurityConfig.java index 7b8f4f2..293caad 100644 --- a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/SecurityConfig.java @@ -1,7 +1,8 @@ -package com.won.smarketing.common.config; +package com.won.smarketing.member.config; import com.won.smarketing.common.security.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -25,10 +26,13 @@ import java.util.Arrays; @Configuration @EnableWebSecurity @RequiredArgsConstructor -public class SecurityConfig { +public class SecurityConfig +{ private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Value("${allowed-origins}") + private String allowedOrigins; /** * Spring Security 필터 체인 설정 * @@ -43,9 +47,10 @@ public class SecurityConfig { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", + .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", - "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error").permitAll() + "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error" + ).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -71,7 +76,7 @@ public class SecurityConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(Arrays.asList("*")); configuration.setAllowCredentials(true); diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml index 92741bc..912bca3 100644 --- a/smarketing-java/member/src/main/resources/application.yml +++ b/smarketing-java/member/src/main/resources/application.yml @@ -53,4 +53,6 @@ info: app: name: ${APP_NAME:smarketing-member} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - member" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - member" + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} diff --git a/smarketing-java/store/build.gradle b/smarketing-java/store/build.gradle index ef65d80..771a2fc 100644 --- a/smarketing-java/store/build.gradle +++ b/smarketing-java/store/build.gradle @@ -1,10 +1,4 @@ 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' - - implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3' } \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/SecurityConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/SecurityConfig.java new file mode 100644 index 0000000..98fdc41 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/SecurityConfig.java @@ -0,0 +1,88 @@ +package com.won.smarketing.store.config; + +import com.won.smarketing.common.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +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; + + @Value("${allowed-origins}") + private String allowedOrigins; + /** + * 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/**", "/actuator/**", "/health/**", "/error" + ).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.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); + 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/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 index ebb72c6..0cdbbf0 100644 --- 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 @@ -46,12 +46,12 @@ public class StoreCreateRequest { @Schema(description = "좌석 수", example = "20") private Integer seatCount; - @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Schema(description = "SNS 계정 정보", example = "@mystore") @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") private String instaAccounts; @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") - @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Schema(description = "블로그 계정 정보", example = "mystore") private String blogAccounts; @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") 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 index 9c0bce0..8bc2c95 100644 --- 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 @@ -47,10 +47,10 @@ public class StoreResponse { @Schema(description = "좌석 수", example = "20") private Integer seatCount; - @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Schema(description = "블로그 계정 정보", example = "mystore") private String blogAccounts; - @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + @Schema(description = "인스타 계정 정보", example = "@mystore") private String instaAccounts; @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") 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 index 1d235b7..acb119d 100644 --- 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 @@ -43,11 +43,11 @@ public class StoreUpdateRequest { @Schema(description = "좌석 수", example = "20") private Integer seatCount; - @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + @Schema(description = "인스타 계정 정보", example = "@mystore") @Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다") private String instaAccounts; - @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Schema(description = "블로그 계정 정보", example = "mystore") @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") private String blogAccounts; diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index 18a8934..42a2488 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -68,4 +68,6 @@ info: app: name: ${APP_NAME:smarketing-content} version: "1.0.0-MVP" - description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file + description: "AI 마케팅 서비스 MVP - content" + +allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000} \ No newline at end of file