Merge branch 'main' into marketing-contents
1
smarketing-ai/GITOPS_TEST.md
Normal file
@ -0,0 +1 @@
|
||||
# GitOps Test Thu Jun 19 07:13:03 UTC 2025
|
||||
@ -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']:
|
||||
|
||||
308
smarketing-ai/deployment/Jenkinsfile
vendored
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
176
smarketing-ai/deployment/Jenkinsfile_backup
Normal file
@ -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 "=========================================="
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -24,4 +24,4 @@ server_host=0.0.0.0
|
||||
server_port=5001
|
||||
|
||||
# Azure Storage Settings (non-sensitive)
|
||||
azure_storage_container_name=ai-content
|
||||
azure_storage_container_name=ai-content
|
||||
|
||||
@ -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": "서울시 강남구 역삼동",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# 기존 모델들은 유지
|
||||
|
||||
0
smarketing-ai/models/예시.md
Normal file
@ -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
|
||||
|
||||
@ -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'<br>\s*<br>\s*<br>', '<br><br>', content) # 3개 이상의 연속 <br>을 2개로
|
||||
|
||||
# 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환
|
||||
elif request.platform == '네이버 블로그' and image_placement_plan:
|
||||
content = self._replace_image_tags_with_html(content, image_placement_plan, request.images)
|
||||
|
||||
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 190 KiB |
1
smarketing-java/GITOPS_TEST.md
Normal file
@ -0,0 +1 @@
|
||||
# GitOps Test Thu Jun 19 05:36:03 UTC 2025
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
description: "AI 마케팅 서비스 MVP - recommend"
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
@ -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') {
|
||||
|
||||
367
smarketing-java/deployment/Jenkinsfile
vendored
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
301
smarketing-java/deployment/Jenkinsfile.backup
Normal file
@ -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
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
smarketing-java/deployment/argocd.yaml
Normal file
@ -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 # 자체 서명 인증서 사용 비활성화 (외부 인증서 사용 시)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
30
smarketing-java/deployment/member/member-deployment.yaml
Normal file
@ -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"
|
||||
12
smarketing-java/deployment/member/member-service.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: member
|
||||
namespace: smarketing
|
||||
spec:
|
||||
selector:
|
||||
app: member
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
type: ClusterIP
|
||||
@ -1,7 +1,4 @@
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// WebClient를 위한 Spring WebFlux 의존성
|
||||
implementation 'org.springframework.boot:spring-boot-starter-webflux'
|
||||
}
|
||||
@ -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<MultipartFile> images, PosterContentCreateRequest request) {
|
||||
|
||||
String generatedPoster = aiPosterGenerator.generatePoster(request);
|
||||
// 1. 이미지 blob storage에 저장하고 request 저장
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
@ -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<MultipartFile> files) {
|
||||
//파일들 주소 가져옴
|
||||
List<String> urls = blobStorageService.uploadImage(files);
|
||||
request.setImages(urls);
|
||||
if(files != null) {
|
||||
List<String> urls = blobStorageService.uploadImage(files, contentImageContainer);
|
||||
request.setImages(urls);
|
||||
}
|
||||
|
||||
// AI를 사용하여 SNS 콘텐츠 생성
|
||||
String content = aiContentGenerator.generateSnsContent(request);
|
||||
|
||||
@ -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<MultipartFile> images, PosterContentCreateRequest request);
|
||||
|
||||
/**
|
||||
* 포스터 콘텐츠 저장
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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<String> hashtags = new ArrayList<>();
|
||||
|
||||
@Builder.Default
|
||||
private List<String> images = new ArrayList<>();
|
||||
|
||||
// ==================== 상태 관리 ====================
|
||||
private ContentStatus status;
|
||||
|
||||
// ==================== 생성 조건 ====================
|
||||
private CreationConditions creationConditions;
|
||||
|
||||
// ==================== 매장 정보 ====================
|
||||
private Long storeId;
|
||||
|
||||
// ==================== 프로모션 기간 ====================
|
||||
private LocalDateTime promotionStartDate;
|
||||
|
||||
private LocalDateTime promotionEndDate;
|
||||
|
||||
// ==================== 메타데이터 ====================
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<MenuData> menuDataList;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ public interface BlobStorageService {
|
||||
* @param file 업로드할 파일
|
||||
* @return 업로드된 파일의 URL
|
||||
*/
|
||||
List<String> uploadImage(List<MultipartFile> file);
|
||||
List<String> uploadImage(List<MultipartFile> file, String containerName);
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@ -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<String> uploadImage(List<MultipartFile> files) {
|
||||
public List<String> uploadImage(List<MultipartFile> files, String containerName) {
|
||||
// 파일 유효성 검증
|
||||
validateImageFile(files);
|
||||
List<String> 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<MultipartFile> 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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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<String, Object> requestBody = buildRequestBody(request);
|
||||
Map<String, Object> 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<String, Object> buildRequestBody(PosterContentCreateRequest request) {
|
||||
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
|
||||
// TODO : 매장 정보 호출 후 request
|
||||
|
||||
// StoreData storeData = storeWithMenuData.getStoreData();
|
||||
// List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
|
||||
//
|
||||
// List<Map<String, Object>> menuList = menuDataList.stream()
|
||||
// .map(menu -> {
|
||||
// Map<String, Object> menuMap = new HashMap<>();
|
||||
// menuMap.put("menu_id", menu.getMenuId());
|
||||
// menuMap.put("menu_name", menu.getMenuName());
|
||||
// menuMap.put("category", menu.getCategory());
|
||||
// menuMap.put("price", menu.getPrice());
|
||||
// menuMap.put("description", menu.getDescription());
|
||||
// return menuMap;
|
||||
// })
|
||||
// .collect(Collectors.toList());
|
||||
//
|
||||
// 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());
|
||||
|
||||
@ -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<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
|
||||
|
||||
StoreWithMenuData result = StoreWithMenuData.builder()
|
||||
.storeData(storeData)
|
||||
.menuDataList(menuDataList)
|
||||
.build();
|
||||
|
||||
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
|
||||
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
|
||||
|
||||
return result;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
|
||||
|
||||
// 실패 시 Mock 데이터 반환
|
||||
return StoreWithMenuData.builder()
|
||||
.storeData(createMockStoreData(userId))
|
||||
.menuDataList(createMockMenuData(6L))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
public StoreData getStoreDataByUserId(String userId) {
|
||||
try {
|
||||
log.debug("매장 정보 실시간 조회: userId={}", userId);
|
||||
return callStoreServiceByUserId(userId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
|
||||
return createMockStoreData(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public List<MenuData> getMenusByStoreId(Long storeId) {
|
||||
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
|
||||
|
||||
try {
|
||||
return callMenuService(storeId);
|
||||
} catch (Exception e) {
|
||||
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
|
||||
return createMockMenuData(storeId);
|
||||
}
|
||||
}
|
||||
|
||||
private StoreData callStoreServiceByUserId(String userId) {
|
||||
|
||||
try {
|
||||
StoreApiResponse response = webClient
|
||||
.get()
|
||||
.uri(storeServiceBaseUrl + "/api/store")
|
||||
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
|
||||
.retrieve()
|
||||
.bodyToMono(StoreApiResponse.class)
|
||||
.timeout(Duration.ofMillis(timeout))
|
||||
.block();
|
||||
|
||||
log.info("response : {}", response.getData().getStoreName());
|
||||
log.info("response : {}", response.getData().getStoreId());
|
||||
|
||||
if (response != null && response.getData() != null) {
|
||||
StoreApiResponse.StoreInfo storeInfo = response.getData();
|
||||
return StoreData.builder()
|
||||
.storeId(storeInfo.getStoreId())
|
||||
.storeName(storeInfo.getStoreName())
|
||||
.businessType(storeInfo.getBusinessType())
|
||||
.location(storeInfo.getAddress())
|
||||
.description(storeInfo.getDescription())
|
||||
.seatCount(storeInfo.getSeatCount())
|
||||
.build();
|
||||
}
|
||||
} catch (WebClientResponseException e) {
|
||||
if (e.getStatusCode().value() == 404) {
|
||||
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
|
||||
}
|
||||
log.error("매장 서비스 호출 실패: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return createMockStoreData(userId);
|
||||
}
|
||||
|
||||
private String getCurrentJwtToken() {
|
||||
try {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
|
||||
if (attributes == null) {
|
||||
log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음");
|
||||
return null;
|
||||
}
|
||||
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
|
||||
|
||||
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
|
||||
String token = bearerToken.substring(BEARER_PREFIX.length());
|
||||
log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length())));
|
||||
return token;
|
||||
} else {
|
||||
log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken);
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<MenuData> callMenuService(Long storeId) {
|
||||
try {
|
||||
MenuApiResponse response = webClient
|
||||
.get()
|
||||
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
|
||||
.retrieve()
|
||||
.bodyToMono(MenuApiResponse.class)
|
||||
.timeout(Duration.ofMillis(timeout))
|
||||
.block();
|
||||
|
||||
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
|
||||
List<MenuData> menuDataList = response.getData().stream()
|
||||
.map(this::toMenuData)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
|
||||
return menuDataList;
|
||||
}
|
||||
} catch (WebClientResponseException e) {
|
||||
if (e.getStatusCode().value() == 404) {
|
||||
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
|
||||
} catch (WebClientException e) {
|
||||
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
|
||||
}
|
||||
|
||||
return createMockMenuData(storeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* MenuResponse를 MenuData로 변환
|
||||
*/
|
||||
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
|
||||
return MenuData.builder()
|
||||
.menuId(menuInfo.getMenuId())
|
||||
.menuName(menuInfo.getMenuName())
|
||||
.category(menuInfo.getCategory())
|
||||
.price(menuInfo.getPrice())
|
||||
.description(menuInfo.getDescription())
|
||||
.build();
|
||||
}
|
||||
|
||||
private StoreData createMockStoreData(String userId) {
|
||||
return StoreData.builder()
|
||||
.storeName("테스트 카페 " + userId)
|
||||
.businessType("카페")
|
||||
.location("서울시 강남구")
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<MenuData> createMockMenuData(Long storeId) {
|
||||
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
|
||||
|
||||
return List.of(
|
||||
MenuData.builder()
|
||||
.menuId(1L)
|
||||
.menuName("아메리카노")
|
||||
.category("음료")
|
||||
.price(4000)
|
||||
.description("깊고 진한 맛의 아메리카노")
|
||||
.build(),
|
||||
MenuData.builder()
|
||||
.menuId(2L)
|
||||
.menuName("카페라떼")
|
||||
.category("음료")
|
||||
.price(4500)
|
||||
.description("부드러운 우유 거품이 올라간 카페라떼")
|
||||
.build(),
|
||||
MenuData.builder()
|
||||
.menuId(3L)
|
||||
.menuName("치즈케이크")
|
||||
.category("디저트")
|
||||
.price(6000)
|
||||
.description("진한 치즈 맛의 수제 케이크")
|
||||
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
@Getter
|
||||
private static class StoreApiResponse {
|
||||
private int status;
|
||||
private String message;
|
||||
private StoreInfo data;
|
||||
|
||||
public int getStatus() { return status; }
|
||||
public void setStatus(int status) { this.status = status; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public StoreInfo getData() { return data; }
|
||||
public void setData(StoreInfo data) { this.data = data; }
|
||||
|
||||
@Getter
|
||||
static class StoreInfo {
|
||||
private Long storeId;
|
||||
private String storeName;
|
||||
private String businessType;
|
||||
private String address;
|
||||
private String description;
|
||||
private Integer seatCount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu API 응답 DTO (새로 추가)
|
||||
*/
|
||||
private static class MenuApiResponse {
|
||||
private List<MenuInfo> data;
|
||||
private String message;
|
||||
private boolean success;
|
||||
|
||||
public List<MenuInfo> getData() { return data; }
|
||||
public void setData(List<MenuInfo> data) { this.data = data; }
|
||||
public String getMessage() { return message; }
|
||||
public void setMessage(String message) { this.message = message; }
|
||||
public boolean isSuccess() { return success; }
|
||||
public void setSuccess(boolean success) { this.success = success; }
|
||||
|
||||
public static class MenuInfo {
|
||||
private Long menuId;
|
||||
private String menuName;
|
||||
private String category;
|
||||
private Integer price;
|
||||
private String description;
|
||||
private String image;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Long getMenuId() { return menuId; }
|
||||
public void setMenuId(Long menuId) { this.menuId = menuId; }
|
||||
public String getMenuName() { return menuName; }
|
||||
public void setMenuName(String menuName) { this.menuName = menuName; }
|
||||
public String getCategory() { return category; }
|
||||
public void setCategory(String category) { this.category = category; }
|
||||
public Integer getPrice() { return price; }
|
||||
public void setPrice(Integer price) { this.price = price; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getImage() { return image; }
|
||||
public void setImage(String image) { this.image = image; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestPart("request") String requestJson,
|
||||
@Valid @RequestPart("files") List<MultipartFile> images) throws JsonProcessingException {
|
||||
@Valid @RequestPart(name = "files", required = false) List<MultipartFile> 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<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) {
|
||||
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request);
|
||||
@PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(
|
||||
@Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE))
|
||||
@RequestPart(value = "images", required = false) List<MultipartFile> 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 저장 성공 응답
|
||||
*/
|
||||
|
||||
@ -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<String> images;
|
||||
|
||||
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
||||
|
||||
@ -31,19 +31,9 @@ public class PosterContentCreateResponse {
|
||||
@Schema(description = "생성된 포스터 타입")
|
||||
private String contentType;
|
||||
|
||||
@Schema(description = "포스터 이미지 URL")
|
||||
private String posterImage;
|
||||
|
||||
@Schema(description = "원본 이미지 URL 목록")
|
||||
private List<String> originalImages;
|
||||
|
||||
@Schema(description = "이미지 스타일", example = "모던")
|
||||
private String imageStyle;
|
||||
|
||||
@Schema(description = "생성 상태", example = "DRAFT")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "포스터사이즈", example = "800x600")
|
||||
private Map<String, String> posterSizes;
|
||||
|
||||
}
|
||||
@ -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<String> images;
|
||||
|
||||
@Schema(description = "발행 상태", example = "PUBLISHED")
|
||||
private String status;
|
||||
|
||||
// CreationConditions에 필요한 필드들
|
||||
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
|
||||
private String requirement;
|
||||
|
||||
@Schema(description = "톤앤매너", example = "전문적")
|
||||
private String toneAndManner;
|
||||
|
||||
@Schema(description = "감정 강도", example = "보통")
|
||||
private String emotionIntensity;
|
||||
|
||||
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
|
||||
private String eventName;
|
||||
|
||||
|
||||
@ -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자 이하로 입력해주세요")
|
||||
|
||||
@ -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"
|
||||
description: "AI 마케팅 서비스 MVP - content"
|
||||
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
@ -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);
|
||||
@ -53,4 +53,6 @@ info:
|
||||
app:
|
||||
name: ${APP_NAME:smarketing-member}
|
||||
version: "1.0.0-MVP"
|
||||
description: "AI 마케팅 서비스 MVP - member"
|
||||
description: "AI 마케팅 서비스 MVP - member"
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||
|
||||
@ -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'
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 = "따뜻한 분위기의 동네 카페입니다.")
|
||||
|
||||
@ -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 = "따뜻한 분위기의 동네 카페입니다.")
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -68,4 +68,6 @@ info:
|
||||
app:
|
||||
name: ${APP_NAME:smarketing-content}
|
||||
version: "1.0.0-MVP"
|
||||
description: "AI 마케팅 서비스 MVP - content"
|
||||
description: "AI 마케팅 서비스 MVP - content"
|
||||
|
||||
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||