diff --git a/smarketing-ai/deployment/Dockerfile b/smarketing-ai/deployment/Dockerfile index 223ed21..6808aa8 100644 --- a/smarketing-ai/deployment/Dockerfile +++ b/smarketing-ai/deployment/Dockerfile @@ -2,11 +2,12 @@ FROM python:3.11-slim WORKDIR /app -COPY requirements.txt . +# 경로 수정 +COPY smarketing-ai/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 애플리케이션 코드 복사 -COPY . . +COPY smarketing-ai/ . # 포트 노출 EXPOSE 5001 diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile new file mode 100644 index 0000000..e55f855 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile @@ -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/Jenkinsfile_ArgoCD b/smarketing-ai/deployment/Jenkinsfile_ArgoCD new file mode 100644 index 0000000..1f86a02 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile_ArgoCD @@ -0,0 +1,170 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true), + containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true) + ], + volumes: [ + emptyDirVolume(mountPath: '/run/podman', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + + stage("Get Source") { + checkout scm + props = readProperties file: "deployment/deploy_env_vars" + } + + stage('Build & Push Docker Image') { + container('podman') { + sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2' + + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "==========================================" + echo "Building smarketing-ai for ArgoCD GitOps" + echo "Image Tag: ${imageTag}" + echo "==========================================" + + # ACR 로그인 + echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin + + # Docker 이미지 빌드 + podman build \ + -f deployment/container/Dockerfile \ + -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . + + # 이미지 푸시 + podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + """ + } + } + } + + stage('Update Manifest Repository') { + container('git') { + withCredentials([usernamePassword( + credentialsId: 'github-credentials-${props.teamid}', + usernameVariable: 'GIT_USERNAME', + passwordVariable: 'GIT_PASSWORD' + )]) { + sh """ + # Git 설정 + git config --global user.email "jenkins@company.com" + git config --global user.name "Jenkins CI" + + # Manifest 저장소 클론 (팀별 저장소로 수정 필요) + git clone https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/your-team/smarketing-ai-manifest.git + cd smarketing-ai-manifest + + echo "==========================================" + echo "Updating smarketing-ai manifest repository:" + echo "New Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + + # smarketing deployment 파일 업데이트 + if [ -f "smarketing/smarketing-deployment.yaml" ]; then + # 이미지 태그 업데이트 + sed -i "s|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}|g" \ + smarketing/smarketing-deployment.yaml + + echo "Updated smarketing deployment to image tag: ${imageTag}" + cat smarketing/smarketing-deployment.yaml | grep "image:" + else + echo "Warning: smarketing-deployment.yaml not found" + echo "Creating manifest directory structure..." + + # 기본 구조 생성 + mkdir -p smarketing + + # 기본 deployment 파일 생성 + cat > smarketing/smarketing-deployment.yaml << EOF +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: smarketing + labels: + app: smarketing +spec: + replicas: 1 + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: 256m + memory: 512Mi + limits: + cpu: 1024m + memory: 2048Mi + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} +EOF + echo "Created basic smarketing-deployment.yaml" + fi + + # 변경사항 커밋 및 푸시 + git add . + git commit -m "Update smarketing-ai image tag to ${imageTag} + + Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + Build: ${env.BUILD_NUMBER} + Branch: ${env.BRANCH_NAME} + Commit: ${env.GIT_COMMIT}" + + git push origin main + + echo "==========================================" + echo "ArgoCD GitOps Update Completed!" + echo "Updated Service: smarketing-ai:${imageTag}" + echo "ArgoCD will automatically detect and deploy these changes." + echo "==========================================" + """ + } + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy.yaml.template b/smarketing-ai/deployment/deploy.yaml.template new file mode 100644 index 0000000..2f35b44 --- /dev/null +++ b/smarketing-ai/deployment/deploy.yaml.template @@ -0,0 +1,113 @@ +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: smarketing-config + namespace: ${namespace} +data: + SERVER_HOST: "${server_host}" + SERVER_PORT: "${server_port}" + UPLOAD_FOLDER: "${upload_folder}" + MAX_CONTENT_LENGTH: "${max_content_length}" + ALLOWED_EXTENSIONS: "${allowed_extensions}" + AZURE_STORAGE_CONTAINER_NAME: "${azure_storage_container_name}" + +--- +# Secret +apiVersion: v1 +kind: Secret +metadata: + name: smarketing-secret + namespace: ${namespace} +type: Opaque +stringData: + SECRET_KEY: "${secret_key}" + CLAUDE_API_KEY: "${claude_api_key}" + OPENAI_API_KEY: "${openai_api_key}" + AZURE_STORAGE_ACCOUNT_NAME: "${azure_storage_account_name}" + AZURE_STORAGE_ACCOUNT_KEY: "${azure_storage_account_key}" + +--- +# Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: ${namespace} + labels: + app: smarketing +spec: + replicas: ${replicas} + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: ${smarketing_image_path} + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + livenessProbe: + httpGet: + path: /health + port: 5001 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 5001 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} + +--- +# Service (LoadBalancer type for External IP) +apiVersion: v1 +kind: Service +metadata: + name: smarketing-service + namespace: ${namespace} + labels: + app: smarketing +spec: + type: LoadBalancer + ports: + - port: 5001 + targetPort: 5001 + protocol: TCP + name: http + selector: + app: smarketing \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy_env_vars b/smarketing-ai/deployment/deploy_env_vars new file mode 100644 index 0000000..6f33b33 --- /dev/null +++ b/smarketing-ai/deployment/deploy_env_vars @@ -0,0 +1,27 @@ +# Team Settings +teamid=won +root_project=smarketing-ai +namespace=smarketing + +# Container Registry Settings +registry=acrdigitalgarage02.azurecr.io +image_org=won + +# Application Settings +replicas=1 + +# Resource Settings +resources_requests_cpu=256m +resources_requests_memory=512Mi +resources_limits_cpu=1024m +resources_limits_memory=2048Mi + +# Flask App Settings (non-sensitive) +upload_folder=/app/uploads +max_content_length=16777216 +allowed_extensions=png,jpg,jpeg,gif,webp +server_host=0.0.0.0 +server_port=5001 + +# Azure Storage Settings (non-sensitive) +azure_storage_container_name=ai-content \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml index d013ead..e489d01 100644 --- a/smarketing-ai/deployment/manifest/secret.yaml +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -5,6 +5,10 @@ metadata: namespace: smarketing type: Opaque stringData: - SECRET_KEY: "your-secret-key-change-in-production" - CLAUDE_API_KEY: "your-claude-api-key" - OPENAI_API_KEY: "your-openai-api-key" \ No newline at end of file + SECRET_KEY: + CLAUDE_API_KEY: + OPENAI_API_KEY: + AZURE_STORAGE_ACCOUNT_NAME: "stdigitalgarage02" + AZURE_STORAGE_ACCOUNT_KEY: + AZURE_STORAGE_CONTAINER_NAME: "ai-content" + diff --git a/smarketing-ai/services/marketing_tip_service.py b/smarketing-ai/services/marketing_tip_service.py index db5526a..deceb3c 100644 --- a/smarketing-ai/services/marketing_tip_service.py +++ b/smarketing-ai/services/marketing_tip_service.py @@ -101,7 +101,8 @@ class MarketingTipService: 당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 낼 수 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다. 지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요. -소상공인을 위한 실용적인 마케팅 팁을 생성해주세요. +소상공인을 위한 현실적이고 바로 실행할 수 있는 실용적인 마케팅 팁을 생성해주세요. +협업보다는 할인, 포스팅 등 당장 실현 가능한 현실적이면서도 창의적인 방법을 추천해주세요. 매장 정보: - 매장명: {store_name} @@ -123,17 +124,13 @@ class MarketingTipService: prompt += """ 아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요: -1. **실행 가능성**: 소상공인이 실제로 적용할 수 있는 현실적인 방법 +1. **실행 가능성**: 소상공인이 실제로 바로 적용할 수 있는 현실적인 방법 2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 수 있는 전략 3. **구체성**: 실행 단계가 명확하고 구체적일 것 4. **시의성**: 현재 계절, 유행, 트렌드를 반영 5. **지역성**: 지역 특성 및 현재 날씨를 고려할 것 -응답 형식 (300자 내외, 간결하게): -html 형식으로 출력 -핵심 마케팅 팁은 제목없이 한번 더 상단에 보여주세요 -부제목과 내용은 분리해서 출력 -아래의 부제목 앞에는 이모지 포함 +출력해야할 내용: - 핵심 마케팅 팁 (1개) - 실행 방법 (1개) - 예상 비용과 기대 효과 @@ -141,6 +138,27 @@ html 형식으로 출력 - 참고했던 실제 성공한 마케팅 - 오늘의 응원의 문장 (간결하게 1개) +아래 HTML 템플릿 형식으로 응답해주세요.

태그는 절대 변경하지 말고,

태그 내용만 새로 작성해주세요 +

태그 내용 외에 다른 내용은 절대 넣지 마세요 : + +

✨ 핵심 마케팅 팁

+

[여기에 새로운 핵심 마케팅 팁 작성]

+ +

🚀 실행 방법

+

[여기에 새로운 실행 방법 내용 작성]

+ +

💰 예상 비용과 기대 효과

+

[여기에 새로운 비용/효과 내용 작성]

+ +

⚠️ 주의사항

+

[여기에 새로운 주의사항 내용 작성]

+ +

📈 참고했던 실제 성공한 마케팅

+

[여기에 새로운 참고 사례 내용 작성, 존재하지 않는 사례는 절대 참고하지 말고, 실제 존재하는 마케팅 성공 사례로만 작성. 참고했던 존재하는 url로 함께 표기]

+ +

🙌 오늘의 응원의 문장

+

[여기에 응원의 문장 작성]

+ 심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요. """ diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index 680a1e7..16fd8ba 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -1714,8 +1714,7 @@ class SnsContentService: 6. 해시태그는 본문과 자연스럽게 연결되도록 배치 **필수 요구사항:** -{request.requirement #or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' -} +{request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' 인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요. 필수 요구사항을 반드시 참고하여 작성해주세요. @@ -1788,9 +1787,7 @@ class SnsContentService: - 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 **필수 요구사항:** -{request.requirement - # or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' -} +{request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' 네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. 필수 요구사항을 반드시 참고하여 작성해주세요. diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index e6654cf..ee19059 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -104,13 +104,12 @@ public class MarketingTipService implements MarketingTipUseCase { log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); String tipSummary = generateTipSummary(aiGeneratedTip); - log.info("tipSummary : {}", tipSummary); // 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(storeWithMenuData.getStoreData().getStoreId()) - .tipContent(aiGeneratedTip) .tipSummary(tipSummary) + .tipContent(aiGeneratedTip) .storeWithMenuData(storeWithMenuData) .createdAt(LocalDateTime.now()) .build(); @@ -142,113 +141,80 @@ public class MarketingTipService implements MarketingTipUseCase { .build(); } + /** + * 마케팅 팁 요약 생성 (핵심 마케팅 팁 섹션에서 첫 번째 문장 추출) + * + * @param fullContent AI로 생성된 전체 마케팅 팁 HTML 콘텐츠 + * @return 핵심 마케팅 팁의 첫 번째 문장 + */ private String generateTipSummary(String fullContent) { if (fullContent == null || fullContent.trim().isEmpty()) { return "마케팅 팁이 생성되었습니다."; } try { - // JSON 형식 처리: "```html\n..." 패턴 - String processedContent = preprocessContent(fullContent); + // 1. "✨ 핵심 마케팅 팁" 섹션 추출 + String coreSection = extractCoreMarketingTipSection(fullContent); - // 1순위: HTML 블록 밖의 첫 번째 제목 추출 - String titleOutsideHtml = extractTitleOutsideHtml(processedContent); - if (titleOutsideHtml != null && titleOutsideHtml.length() > 5) { - return titleOutsideHtml; + if (coreSection != null && !coreSection.trim().isEmpty()) { + // 2. HTML 태그 제거 + String cleanText = removeHtmlTags(coreSection); + + // 3. 첫 번째 의미있는 문장 추출 + String summary = extractFirstMeaningfulSentence(cleanText); + + // 4. 길이 제한 (100자 이내) + if (summary.length() > 100) { + summary = summary.substring(0, 97) + "..."; + } + + return summary; } - // 2순위: 태그 안의 첫 번째 내용 추출 - String boldContent = extractBoldContent(processedContent); - if (boldContent != null && boldContent.length() > 5) { - return boldContent; - } - - // 3순위: HTML 태그 제거 후 첫 번째 문장 - return extractFirstSentence(processedContent); + // 핵심 팁 섹션을 찾지 못한 경우 fallback 처리 + return extractFallbackSummary(fullContent); } catch (Exception e) { - log.error("마케팅 팁 요약 생성 중 오류", e); - return "마케팅 팁이 생성되었습니다."; + log.warn("마케팅 팁 요약 생성 중 오류 발생, 기본 메시지 반환: {}", e.getMessage()); + return "맞춤형 마케팅 팁이 생성되었습니다."; } } /** - * JSON이나 특수 형식 전처리 + * "✨ 핵심 마케팅 팁" 섹션 추출 */ - private String preprocessContent(String content) { - // 먼저 JSON 이스케이프 문자 정리 - if (content.contains("\\n")) { - content = content.replaceAll("\\\\n", "\n"); - } + private String extractCoreMarketingTipSection(String fullContent) { + // 핵심 마케팅 팁 섹션 시작 패턴들 + String[] corePatterns = { + "✨ 핵심 마케팅 팁", + "

✨ 핵심 마케팅 팁

", + "핵심 마케팅 팁" + }; - // JSON 구조에서 실제 HTML 내용만 추출 - if (content.contains("```html")) { - content = content.replaceAll("```html", "") - .replaceAll("```", "") - .replaceAll("\"", ""); - } + // 다음 섹션 시작 패턴들 + String[] nextSectionPatterns = { + "🚀 실행 방법", + "

🚀 실행 방법

", + "💰 예상 비용", + "

💰 예상 비용" + }; - return content.trim(); - } + for (String pattern : corePatterns) { + int startIndex = fullContent.indexOf(pattern); + if (startIndex != -1) { + // 패턴 뒤부터 시작 + int contentStart = startIndex + pattern.length(); - /** - * HTML 블록 밖의 첫 번째 제목 라인 추출 - * ```html 이후 첫 번째 줄의 내용만 추출 - */ - private String extractTitleOutsideHtml(String content) { - // 먼저 이스케이프 문자 정리 - String processedContent = content.replaceAll("\\\\n", "\n"); - - // ```html 패턴 찾기 (이스케이프 처리 후) - String[] htmlPatterns = {"```html\n", "```html\\n"}; - - for (String pattern : htmlPatterns) { - int htmlStart = processedContent.indexOf(pattern); - if (htmlStart != -1) { - // 패턴 이후부터 시작 - int contentStart = htmlStart + pattern.length(); - - // 첫 번째 줄바꿈까지 또는 \n\n까지 찾기 - String remaining = processedContent.substring(contentStart); - String[] lines = remaining.split("\n"); - - if (lines.length > 0) { - String firstLine = lines[0].trim(); - - // 유효한 내용인지 확인 - if (firstLine.length() > 5 && !firstLine.contains("🎯") && !firstLine.contains("<")) { - return cleanText(firstLine); + // 다음 섹션까지의 내용 추출 + int endIndex = fullContent.length(); + for (String nextPattern : nextSectionPatterns) { + int nextIndex = fullContent.indexOf(nextPattern, contentStart); + if (nextIndex != -1 && nextIndex < endIndex) { + endIndex = nextIndex; } } - } - } - // 기존 방식으로 fallback - return extractFromLines(processedContent); - } - - /** - * 줄별로 처리하는 기존 방식 - */ - private String extractFromLines(String content) { - String[] lines = content.split("\n"); - - for (String line : lines) { - line = line.trim(); - - // 빈 줄이나 HTML 태그, 이모지로 시작하는 줄 건너뛰기 - if (line.isEmpty() || - line.contains("<") || - line.startsWith("🎯") || - line.startsWith("🔍") || - line.equals("```html") || - line.matches("^[\\p{So}\\p{Sk}\\s]+$")) { - continue; - } - - // 의미있는 제목 라인 발견 - if (line.length() > 5) { - return cleanText(line); + return fullContent.substring(contentStart, endIndex).trim(); } } @@ -256,73 +222,87 @@ public class MarketingTipService implements MarketingTipUseCase { } /** - * 태그 안의 첫 번째 내용 추출 + * HTML 태그 제거 */ - private String extractBoldContent(String htmlContent) { - int startIndex = htmlContent.indexOf(""); - if (startIndex == -1) { - return null; - } + private String removeHtmlTags(String htmlText) { + if (htmlText == null) return ""; - int endIndex = htmlContent.indexOf("", startIndex); - if (endIndex == -1) { - return null; - } - - String content = htmlContent.substring(startIndex + 3, endIndex).trim(); - return cleanText(content); - } - - /** - * 텍스트 정리 - */ - private String cleanText(String text) { - if (text == null) { - return null; - } - - return text.replaceAll(" ", " ") - .replaceAll("\\s+", " ") + return htmlText + .replaceAll("<[^>]+>", "") // HTML 태그 제거 + .replaceAll(" ", " ") // HTML 엔티티 처리 + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") + .replaceAll("\\s+", " ") // 연속된 공백을 하나로 .trim(); } /** - * HTML 태그 제거 후 첫 번째 의미있는 문장 추출 + * 첫 번째 의미있는 문장 추출 */ - private String extractFirstSentence(String htmlContent) { - // HTML 태그 모두 제거 - String cleanContent = htmlContent.replaceAll("<[^>]+>", "").trim(); + private String extractFirstMeaningfulSentence(String cleanText) { + if (cleanText == null || cleanText.trim().isEmpty()) { + return "마케팅 팁이 생성되었습니다."; + } - // 줄별로 나누어서 첫 번째 의미있는 줄 찾기 - String[] lines = cleanContent.split("\\n"); + // 문장 분할 (마침표, 느낌표, 물음표 기준) + String[] sentences = cleanText.split("[.!?]"); - for (String line : lines) { - line = line.trim(); + for (String sentence : sentences) { + String trimmed = sentence.trim(); - // 빈 줄이나 이모지만 있는 줄 건너뛰기 - if (line.isEmpty() || line.matches("^[\\p{So}\\p{Sk}\\s]+$")) { - continue; - } + // 의미있는 문장인지 확인 (10자 이상, 특수문자만으로 구성되지 않음) + if (trimmed.length() >= 10 && + !trimmed.matches("^[\\s\\p{Punct}]*$") && // 공백과 구두점만으로 구성되지 않음 + !isOnlyEmojisOrSymbols(trimmed)) { // 이모지나 기호만으로 구성되지 않음 - // 최소 길이 체크하고 반환 - if (line.length() > 5) { - // 50자 제한 - if (line.length() > 50) { - return line.substring(0, 50).trim() + "..."; + // 문장 끝에 마침표 추가 (없는 경우) + if (!trimmed.endsWith(".") && !trimmed.endsWith("!") && !trimmed.endsWith("?")) { + trimmed += "."; } - return line; + + return trimmed; } } - // 모든 방법이 실패하면 기존 방식 사용 - String[] sentences = cleanContent.split("[.!?]"); - String firstSentence = sentences.length > 0 ? sentences[0].trim() : cleanContent; - - if (firstSentence.length() > 50) { - firstSentence = firstSentence.substring(0, 50).trim() + "..."; + // 의미있는 문장을 찾지 못한 경우 원본의 처음 50자 반환 + if (cleanText.length() > 50) { + return cleanText.substring(0, 47) + "..."; } - return firstSentence.isEmpty() ? "마케팅 팁이 생성되었습니다." : firstSentence; + return cleanText; + } + + /** + * 이모지나 기호만으로 구성되었는지 확인 + */ + private boolean isOnlyEmojisOrSymbols(String text) { + // 한글, 영문, 숫자가 포함되어 있으면 의미있는 텍스트로 판단 + return !text.matches(".*[\\p{L}\\p{N}].*"); + } + + /** + * 핵심 팁 섹션을 찾지 못한 경우 대체 요약 생성 + */ + private String extractFallbackSummary(String fullContent) { + // HTML 태그 제거 후 첫 번째 의미있는 문장 찾기 + String cleanContent = removeHtmlTags(fullContent); + + // 첫 번째 문단에서 의미있는 문장 추출 + String[] paragraphs = cleanContent.split("\\n\\n"); + + for (String paragraph : paragraphs) { + String trimmed = paragraph.trim(); + if (trimmed.length() >= 20) { // 충분히 긴 문단 + String summary = extractFirstMeaningfulSentence(trimmed); + if (summary.length() >= 10) { + return summary; + } + } + } + + // 모든 방법이 실패한 경우 기본 메시지 + return "개인화된 마케팅 팁이 생성되었습니다."; } /** diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index ee94915..d392c82 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -43,10 +43,18 @@ management: endpoints: web: exposure: - include: health,info,metrics + include: health,info + base-path: /actuator endpoint: health: show-details: always + info: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true logging: level: @@ -55,4 +63,11 @@ logging: jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} - refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + + +info: + app: + name: ${APP_NAME:smarketing-recommend} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - recommend" \ No newline at end of file diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index 30d5b26..e917ca4 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -35,6 +35,7 @@ subprojects { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' @@ -51,6 +52,7 @@ subprojects { implementation 'com.azure:azure-messaging-eventhubs:5.18.0' implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-identity:1.11.4' + } tasks.named('test') { diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java index 5c61143..7b8f4f2 100644 --- a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -44,8 +44,8 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", - "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", - "/swagger-resources/**", "/webjars/**").permitAll() + "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", + "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile index b281bd7..ce96650 100644 --- a/smarketing-java/deployment/Jenkinsfile +++ b/smarketing-java/deployment/Jenkinsfile @@ -41,6 +41,23 @@ podTemplate( 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" + } + } + stage("Setup AKS") { container('azure-cli') { withCredentials([azureServicePrincipal('azure-credentials')]) { @@ -49,8 +66,8 @@ podTemplate( az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66 - echo "=== AKS 인증정보 가져오기 ===" - az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing + echo "=== 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 - @@ -66,6 +83,9 @@ podTemplate( echo "=== 클러스터 상태 확인 ===" kubectl get nodes kubectl get ns ${namespace} + + echo "=== 현재 연결된 클러스터 확인 ===" + kubectl config current-context """ } } @@ -99,7 +119,7 @@ podTemplate( timeout 30 sh -c 'until docker info; do sleep 1; done' """ - // 🔧 ACR Credential을 Jenkins에서 직접 사용 + // ACR Credential을 Jenkins에서 직접 사용 withCredentials([usernamePassword( credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', @@ -184,6 +204,10 @@ podTemplate( 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 서비스가 없습니다. 먼저 설치해주세요." diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template index 4b88867..92e1068 100644 --- a/smarketing-java/deployment/deploy.yaml.template +++ b/smarketing-java/deployment/deploy.yaml.template @@ -8,6 +8,16 @@ data: ALLOWED_ORIGINS: ${allowed_origins} JPA_DDL_AUTO: update JPA_SHOW_SQL: 'true' + # 🔧 강화된 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 @@ -167,18 +177,29 @@ spec: periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 10 + # 🔧 개선된 Health Check 설정 livenessProbe: httpGet: path: /actuator/health port: 8081 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 # 2분으로 증가 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8081 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 # 1분으로 증가 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- apiVersion: apps/v1 @@ -236,14 +257,24 @@ spec: httpGet: path: /actuator/health port: 8082 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8082 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- apiVersion: apps/v1 @@ -301,14 +332,24 @@ spec: httpGet: path: /actuator/health port: 8083 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8083 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- apiVersion: apps/v1 @@ -366,14 +407,24 @@ spec: httpGet: path: /actuator/health port: 8084 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8084 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- # Services 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 e89b5c5..55a1b19 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 @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.Map; /** @@ -32,25 +33,20 @@ public class PosterContentService implements PosterContentUseCase { /** * 포스터 콘텐츠 생성 - * + * * @param request 포스터 콘텐츠 생성 요청 * @return 생성된 포스터 콘텐츠 정보 */ @Override @Transactional public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { - // AI를 사용하여 포스터 생성 + String generatedPoster = aiPosterGenerator.generatePoster(request); - - // 다양한 사이즈의 포스터 생성 - Map posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster); // 생성 조건 정보 구성 CreationConditions conditions = CreationConditions.builder() .category(request.getCategory()) .requirement(request.getRequirement()) - // .toneAndManner(request.getToneAndManner()) - // .emotionIntensity(request.getEmotionIntensity()) .eventName(request.getEventName()) .startDate(request.getStartDate()) .endDate(request.getEndDate()) @@ -62,47 +58,41 @@ public class PosterContentService implements PosterContentUseCase { .contentType(ContentType.POSTER.name()) .title(request.getTitle()) .posterImage(generatedPoster) - .posterSizes(posterSizes) + .posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함) .status(ContentStatus.DRAFT.name()) - //.createdAt(LocalDateTime.now()) .build(); } /** * 포스터 콘텐츠 저장 - * + * * @param request 포스터 콘텐츠 저장 요청 */ @Override @Transactional public void savePosterContent(PosterContentSaveRequest request) { - // 생성 조건 정보 구성 + // 생성 조건 구성 CreationConditions conditions = CreationConditions.builder() .category(request.getCategory()) .requirement(request.getRequirement()) - // .toneAndManner(request.getToneAndManner()) - // .emotionIntensity(request.getEmotionIntensity()) .eventName(request.getEventName()) .startDate(request.getStartDate()) .endDate(request.getEndDate()) .photoStyle(request.getPhotoStyle()) .build(); - // 콘텐츠 엔티티 생성 및 저장 + // 콘텐츠 엔티티 생성 Content content = Content.builder() .contentType(ContentType.POSTER) - .platform(Platform.GENERAL) // 포스터는 범용 .title(request.getTitle()) - .content(null) // 포스터는 이미지가 주 콘텐츠 - .hashtags(null) + .content(request.getContent()) .images(request.getImages()) .status(ContentStatus.PUBLISHED) .creationConditions(conditions) .storeId(request.getStoreId()) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) .build(); + // 저장 contentRepository.save(content); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java deleted file mode 100644 index 7495966..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +++ /dev/null @@ -1,86 +0,0 @@ -// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java -package com.won.smarketing.content.infrastructure.external; - -import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import -import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -/** - * Claude AI를 활용한 포스터 생성 구현체 - * Clean Architecture의 Infrastructure Layer에 위치 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class ClaudeAiPosterGenerator implements AiPosterGenerator { - - /** - * 포스터 생성 - * - * @param request 포스터 생성 요청 - * @return 생성된 포스터 이미지 URL - */ - @Override - public String generatePoster(PosterContentCreateRequest request) { - try { - // Claude AI API 호출 로직 - String prompt = buildPosterPrompt(request); - - // TODO: 실제 Claude AI API 호출 - // 현재는 더미 데이터 반환 - return generateDummyPosterUrl(request.getTitle()); - - } catch (Exception e) { - log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); - return generateFallbackPosterUrl(); - } - } - - /** - * 다양한 사이즈의 포스터 생성 - * - * @param baseImage 기본 이미지 - * @return 사이즈별 포스터 URL 맵 - */ - @Override - public Map generatePosterSizes(String baseImage) { - Map sizes = new HashMap<>(); - - // 다양한 사이즈 생성 (더미 구현) - sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); - sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); - sizes.put("facebook_post", baseImage + "_1200x630.jpg"); - sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); - - return sizes; - } - - private String buildPosterPrompt(PosterContentCreateRequest request) { - StringBuilder prompt = new StringBuilder(); - prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); - prompt.append("카테고리: ").append(request.getCategory()).append("\n"); - - if (request.getRequirement() != null) { - prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); - } - - if (request.getToneAndManner() != null) { - prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); - } - - return prompt.toString(); - } - - private String generateDummyPosterUrl(String title) { - return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; - } - - private String generateFallbackPosterUrl() { - return "https://dummy-ai-service.com/posters/fallback.jpg"; - } -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java new file mode 100644 index 0000000..c166cd6 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java @@ -0,0 +1,152 @@ +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * Claude AI를 활용한 포스터 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PythonAiPosterGenerator implements AiPosterGenerator { + + private final WebClient webClient; + + @Value("${external.ai-service.base-url}") + private String aiServiceBaseUrl; + + /** + * 포스터 생성 - Python AI 서비스 호출 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + @Override + public String generatePoster(PosterContentCreateRequest request) { + try { + log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl); + + // 요청 데이터 구성 + Map requestBody = buildRequestBody(request); + + log.debug("포스터 생성 요청 데이터: {}", requestBody); + + // Python AI 서비스 호출 + Map response = webClient + .post() + .uri(aiServiceBaseUrl + "/api/ai/poster") + .header("Content-Type", "application/json") + .bodyValue(requestBody) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음 + .block(); + + // 응답에서 content(이미지 URL) 추출 + if (response != null && response.containsKey("content")) { + String imageUrl = (String) response.get("content"); + log.info("AI 포스터 생성 성공: imageUrl={}", imageUrl); + return imageUrl; + } else { + log.warn("AI 포스터 생성 응답에 content가 없음: {}", response); + return generateFallbackPosterUrl(request.getTitle()); + } + + } catch (Exception e) { + log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); + return generateFallbackPosterUrl(request.getTitle()); + } + } + + /** + * 다양한 사이즈의 포스터 생성 (사용하지 않음) + * 1개의 이미지만 생성하므로 빈 맵 반환 + * + * @param baseImage 기본 이미지 URL + * @return 빈 맵 + */ + @Override + public Map generatePosterSizes(String baseImage) { + log.info("포스터 사이즈 변환 기능은 사용하지 않음: baseImage={}", baseImage); + return new HashMap<>(); + } + + /** + * Python AI 서비스 요청 데이터 구성 + * Python 서비스의 PosterContentGetRequest 모델에 맞춤 + */ + private Map buildRequestBody(PosterContentCreateRequest request) { + Map requestBody = new HashMap<>(); + + // 기본 정보 + requestBody.put("title", request.getTitle()); + requestBody.put("category", request.getCategory()); + requestBody.put("contentType", request.getContentType()); + + // 이미지 정보 + if (request.getImages() != null && !request.getImages().isEmpty()) { + requestBody.put("images", request.getImages()); + } + + // 스타일 정보 + if (request.getPhotoStyle() != null) { + requestBody.put("photoStyle", request.getPhotoStyle()); + } + + // 요구사항 + if (request.getRequirement() != null) { + requestBody.put("requirement", request.getRequirement()); + } + + // 톤앤매너 + if (request.getToneAndManner() != null) { + requestBody.put("toneAndManner", request.getToneAndManner()); + } + + // 감정 강도 + if (request.getEmotionIntensity() != null) { + requestBody.put("emotionIntensity", request.getEmotionIntensity()); + } + + // 메뉴명 + if (request.getMenuName() != null) { + requestBody.put("menuName", request.getMenuName()); + } + + // 이벤트 정보 + if (request.getEventName() != null) { + requestBody.put("eventName", request.getEventName()); + } + + // 날짜 정보 (LocalDate를 String으로 변환) + if (request.getStartDate() != null) { + requestBody.put("startDate", request.getStartDate().format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + + if (request.getEndDate() != null) { + requestBody.put("endDate", request.getEndDate().format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + + return requestBody; + } + + /** + * 폴백 포스터 URL 생성 + */ + private String generateFallbackPosterUrl(String title) { + // 기본 포스터 템플릿 URL 반환 + return "https://stdigitalgarage02.blob.core.windows.net/ai-content/fallback-poster.jpg"; + } +} diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 59b0b54..4157124 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -37,3 +37,26 @@ logging: external: ai-service: base-url: ${AI_SERVICE_BASE_URL:http://20.249.139.88:5001} + +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + +info: + app: + name: ${APP_NAME:smarketing-content} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml index 6f1de12..92741bc 100644 --- a/smarketing-java/member/src/main/resources/application.yml +++ b/smarketing-java/member/src/main/resources/application.yml @@ -31,3 +31,26 @@ jwt: logging: level: com.won.smarketing: ${LOG_LEVEL:DEBUG} + +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + +info: + app: + name: ${APP_NAME:smarketing-member} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - member" \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index 4df4894..3288be6 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -8,7 +8,6 @@ import lombok.NoArgsConstructor; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; -import org.springframework.web.multipart.MultipartFile; /** * 메뉴 수정 요청 DTO diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java index d75efc2..d1a6c4f 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -11,7 +11,6 @@ import com.won.smarketing.store.repository.MenuRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.stream.Collectors; @@ -117,6 +116,7 @@ public class MenuServiceImpl implements MenuService { .menuId(menu.getMenuId()) .menuName(menu.getMenuName()) .category(menu.getCategory()) + .image(menu.getImage()) .price(menu.getPrice()) .description(menu.getDescription()) .createdAt(menu.getCreatedAt()) diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index 5a50cbc..18a8934 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -46,3 +46,26 @@ azure: menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images} store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images} max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB + +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true + +info: + app: + name: ${APP_NAME:smarketing-content} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file