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())
|
app.logger.error(traceback.format_exc())
|
||||||
return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500
|
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():
|
def generate_poster_content():
|
||||||
"""
|
"""
|
||||||
홍보 포스터 생성 API
|
홍보 포스터 생성 API
|
||||||
@ -114,7 +114,7 @@ def create_app():
|
|||||||
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
|
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
|
||||||
|
|
||||||
# 필수 필드 검증
|
# 필수 필드 검증
|
||||||
required_fields = ['title', 'category', 'contentType', 'images']
|
required_fields = ['title', 'category', 'images']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if field not in data:
|
if field not in data:
|
||||||
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
|
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
|
||||||
@ -140,19 +140,14 @@ def create_app():
|
|||||||
poster_request = PosterContentGetRequest(
|
poster_request = PosterContentGetRequest(
|
||||||
title=data.get('title'),
|
title=data.get('title'),
|
||||||
category=data.get('category'),
|
category=data.get('category'),
|
||||||
contentType=data.get('contentType'),
|
|
||||||
images=data.get('images', []),
|
images=data.get('images', []),
|
||||||
photoStyle=data.get('photoStyle'),
|
|
||||||
requirement=data.get('requirement'),
|
requirement=data.get('requirement'),
|
||||||
toneAndManner=data.get('toneAndManner'),
|
|
||||||
emotionIntensity=data.get('emotionIntensity'),
|
|
||||||
menuName=data.get('menuName'),
|
menuName=data.get('menuName'),
|
||||||
eventName=data.get('eventName'),
|
|
||||||
startDate=start_date,
|
startDate=start_date,
|
||||||
endDate=end_date
|
endDate=end_date
|
||||||
)
|
)
|
||||||
|
|
||||||
# 포스터 생성 (V3 사용)
|
# 포스터 생성
|
||||||
result = poster_service.generate_poster(poster_request)
|
result = poster_service.generate_poster(poster_request)
|
||||||
|
|
||||||
if result['success']:
|
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 PIPELINE_ID = "${env.BUILD_NUMBER}"
|
||||||
|
|
||||||
def getImageTag() {
|
def getImageTag() {
|
||||||
@ -11,166 +13,184 @@ podTemplate(
|
|||||||
serviceAccount: 'jenkins',
|
serviceAccount: 'jenkins',
|
||||||
containers: [
|
containers: [
|
||||||
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
|
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: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
|
||||||
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
|
|
||||||
],
|
],
|
||||||
volumes: [
|
volumes: [
|
||||||
emptyDirVolume(mountPath: '/run/podman', memory: false),
|
emptyDirVolume(mountPath: '/run/podman', memory: false)
|
||||||
emptyDirVolume(mountPath: '/root/.azure', memory: false)
|
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
node(PIPELINE_ID) {
|
node(PIPELINE_ID) {
|
||||||
def props
|
def props
|
||||||
def imageTag = getImageTag()
|
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") {
|
try {
|
||||||
checkout scm
|
stage("Get Source") {
|
||||||
props = readProperties file: "smarketing-ai/deployment/deploy_env_vars"
|
checkout scm
|
||||||
namespace = "${props.namespace}"
|
|
||||||
|
// smarketing-ai 하위에 있는 설정 파일 읽기
|
||||||
|
props = readProperties file: "smarketing-ai/deployment/deploy_env_vars"
|
||||||
|
|
||||||
echo "Registry: ${props.registry}"
|
echo "=== Build Information ==="
|
||||||
echo "Image Org: ${props.image_org}"
|
echo "Service: smarketing-ai"
|
||||||
echo "Team ID: ${props.teamid}"
|
echo "Image Tag: ${imageTag}"
|
||||||
}
|
echo "Registry: ${props.registry}"
|
||||||
|
echo "Image Org: ${props.image_org}"
|
||||||
stage("Check Changes") {
|
echo "Team ID: ${props.teamid}"
|
||||||
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") {
|
stage("Check Changes") {
|
||||||
container('azure-cli') {
|
script {
|
||||||
withCredentials([azureServicePrincipal('azure-credentials')]) {
|
def changes = sh(
|
||||||
sh """
|
script: "git diff --name-only HEAD~1 HEAD",
|
||||||
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
|
returnStdout: true
|
||||||
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
|
).trim()
|
||||||
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
|
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') {
|
container('podman') {
|
||||||
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
|
sh 'podman system prune -f || true'
|
||||||
|
|
||||||
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 '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
|
server_port=5001
|
||||||
|
|
||||||
# Azure Storage Settings (non-sensitive)
|
# 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
|
title: str
|
||||||
category: str
|
category: str
|
||||||
contentType: str
|
|
||||||
images: List[str] # 이미지 URL 리스트
|
images: List[str] # 이미지 URL 리스트
|
||||||
photoStyle: Optional[str] = None
|
|
||||||
requirement: Optional[str] = None
|
requirement: Optional[str] = None
|
||||||
toneAndManner: Optional[str] = None
|
|
||||||
emotionIntensity: Optional[str] = None
|
|
||||||
menuName: Optional[str] = None
|
menuName: Optional[str] = None
|
||||||
eventName: Optional[str] = None
|
|
||||||
startDate: Optional[date] = None # LocalDate -> date
|
startDate: Optional[date] = None # LocalDate -> date
|
||||||
endDate: 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)
|
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로 이미지 생성
|
# OpenAI로 이미지 생성
|
||||||
image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1536")
|
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]:
|
def _analyze_main_image(self, image_url: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
메인 메뉴 이미지 분석
|
메인 메뉴 이미지 분석 시작
|
||||||
"""
|
"""
|
||||||
temp_files = []
|
temp_files = []
|
||||||
try:
|
try:
|
||||||
@ -101,7 +101,7 @@ class PosterService:
|
|||||||
if temp_path:
|
if temp_path:
|
||||||
temp_files.append(temp_path)
|
temp_files.append(temp_path)
|
||||||
|
|
||||||
# 이미지 분석
|
# 이미지 분석 시작
|
||||||
image_info = self.image_processor.get_image_info(temp_path)
|
image_info = self.image_processor.get_image_info(temp_path)
|
||||||
image_description = self.ai_client.analyze_image(temp_path)
|
image_description = self.ai_client.analyze_image(temp_path)
|
||||||
colors = self.image_processor.analyze_colors(temp_path, 5)
|
colors = self.image_processor.analyze_colors(temp_path, 5)
|
||||||
@ -125,13 +125,27 @@ class PosterService:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
def _create_poster_prompt_v3(self, request: PosterContentGetRequest,
|
def _create_poster_prompt(self, request: PosterContentGetRequest,
|
||||||
main_analysis: Dict[str, Any]) -> str:
|
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개 포함)
|
포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 7개 포함)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 메인 이미지 정보 활용
|
# 메인 이미지 정보 활용
|
||||||
|
main_image = main_analysis.get('url')
|
||||||
main_description = main_analysis.get('description', '맛있는 음식')
|
main_description = main_analysis.get('description', '맛있는 음식')
|
||||||
main_colors = main_analysis.get('dominant_colors', [])
|
main_colors = main_analysis.get('dominant_colors', [])
|
||||||
image_info = main_analysis.get('info', {})
|
image_info = main_analysis.get('info', {})
|
||||||
@ -150,21 +164,16 @@ class PosterService:
|
|||||||
example_links = "\n".join([f"- {link}" for link in self.example_images])
|
example_links = "\n".join([f"- {link}" for link in self.example_images])
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
## 카페 홍보 포스터 디자인 요청
|
## {main_image}를 활용한 홍보 포스터 디자인 요청
|
||||||
|
|
||||||
### 📋 기본 정보
|
### 📋 기본 정보
|
||||||
카테고리: {request.category}
|
메뉴 이미지 : {main_image}
|
||||||
콘텐츠 타입: {request.contentType}
|
|
||||||
메뉴명: {request.menuName or '없음'}
|
메뉴명: {request.menuName or '없음'}
|
||||||
메뉴 정보: {main_description}
|
메뉴 정보: {main_description}
|
||||||
|
|
||||||
### 📅 이벤트 기간
|
|
||||||
시작일: {request.startDate or '지금'}
|
|
||||||
종료일: {request.endDate or '한정 기간'}
|
|
||||||
이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요.
|
|
||||||
|
|
||||||
### 🎨 디자인 요구사항
|
### 🎨 디자인 요구사항
|
||||||
메인 이미지 처리
|
메인 이미지 처리
|
||||||
|
- {main_image}는 변경 없이 그대로 사용해주세요.
|
||||||
- 기존 메인 이미지는 변경하지 않고 그대로 유지
|
- 기존 메인 이미지는 변경하지 않고 그대로 유지
|
||||||
- 포스터 전체 크기의 1/3 이하로 배치
|
- 포스터 전체 크기의 1/3 이하로 배치
|
||||||
- 이미지와 조화로운 작은 장식 이미지 추가
|
- 이미지와 조화로운 작은 장식 이미지 추가
|
||||||
@ -196,7 +205,78 @@ class PosterService:
|
|||||||
톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼
|
톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼
|
||||||
|
|
||||||
### 🎯 최종 목표
|
### 🎯 최종 목표
|
||||||
고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작
|
고객들이 이 음식을 먹고싶다 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return prompt
|
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': ['방문', '예약', '문의', '공감', '이웃추가'],
|
'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'],
|
||||||
'image_placement_strategy': [
|
'image_placement_strategy': [
|
||||||
'매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기',
|
'매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기',
|
||||||
'텍스트 2-3문장마다 이미지 배치',
|
'텍스트 2-3문장마다 입력받은 이미지 배치',
|
||||||
'이미지 설명은 간결하고 매력적으로',
|
'이미지 설명은 간결하고 매력적으로',
|
||||||
'마지막에 대표 이미지로 마무리'
|
'마지막에 대표 이미지로 마무리'
|
||||||
]
|
]
|
||||||
@ -1560,6 +1560,9 @@ class SnsContentService:
|
|||||||
if not images:
|
if not images:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 🔥 핵심 수정: 실제 이미지 개수 계산
|
||||||
|
actual_image_count = len(request.images) if request.images else 0
|
||||||
|
|
||||||
# 이미지 타입별 분류
|
# 이미지 타입별 분류
|
||||||
categorized_images = {
|
categorized_images = {
|
||||||
'매장외관': [],
|
'매장외관': [],
|
||||||
@ -1603,36 +1606,69 @@ class SnsContentService:
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
'image_sequence': [],
|
'image_sequence': [],
|
||||||
'usage_guide': []
|
'usage_guide': [],
|
||||||
|
'actual_image_count': actual_image_count # 🔥 실제 이미지 수 추가
|
||||||
}
|
}
|
||||||
|
|
||||||
# 각 섹션에 적절한 이미지 배정
|
# 🔥 핵심: 실제 이미지 수에 따라 배치 전략 조정
|
||||||
# 인트로: 매장외관 또는 대표 음식
|
if actual_image_count == 1:
|
||||||
if categorized_images['매장외관']:
|
# 이미지 1개: 가장 대표적인 위치에 배치
|
||||||
placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1])
|
if categorized_images['음식']:
|
||||||
elif categorized_images['음식']:
|
placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'][:1])
|
||||||
placement_plan['structure'][0]['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])
|
||||||
|
|
||||||
# 매장 정보: 외관 + 인테리어
|
elif actual_image_count == 2:
|
||||||
placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관'])
|
# 이미지 2개: 인트로와 메뉴 소개에 각각 배치
|
||||||
placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어'])
|
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])
|
||||||
|
|
||||||
# 메뉴 소개: 메뉴판 + 음식
|
elif actual_image_count == 3:
|
||||||
placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판'])
|
# 이미지 3개: 인트로, 매장 정보, 메뉴 소개에 각각 배치
|
||||||
placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'])
|
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])
|
||||||
|
|
||||||
# 총평: 남은 음식 사진 또는 기타
|
else:
|
||||||
remaining_food = [img for img in categorized_images['음식']
|
# 이미지 4개 이상: 기존 로직 유지하되 실제 이미지 수로 제한
|
||||||
if img not in placement_plan['structure'][2]['recommended_images']]
|
remaining_images = images[:]
|
||||||
placement_plan['structure'][3]['recommended_images'].extend(remaining_food[:1])
|
|
||||||
placement_plan['structure'][3]['recommended_images'].extend(categorized_images['기타'][:1])
|
|
||||||
|
|
||||||
# 전체 이미지 순서 생성
|
# 인트로: 매장외관 또는 대표 음식
|
||||||
|
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 section in placement_plan['structure']:
|
||||||
for img in section['recommended_images']:
|
for img in section['recommended_images']:
|
||||||
if img not in placement_plan['image_sequence']:
|
if img not in placement_plan['image_sequence']:
|
||||||
placement_plan['image_sequence'].append(img)
|
placement_plan['image_sequence'].append(img)
|
||||||
|
|
||||||
|
# 🔥 핵심 수정: 실제 이미지 수만큼만 유지
|
||||||
|
placement_plan['image_sequence'] = placement_plan['image_sequence'][:actual_image_count]
|
||||||
|
|
||||||
# 사용 가이드 생성
|
# 사용 가이드 생성
|
||||||
placement_plan['usage_guide'] = [
|
placement_plan['usage_guide'] = [
|
||||||
"📸 이미지 배치 가이드라인:",
|
"📸 이미지 배치 가이드라인:",
|
||||||
@ -1674,6 +1710,15 @@ class SnsContentService:
|
|||||||
"""
|
"""
|
||||||
category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', [])
|
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"""
|
prompt = f"""
|
||||||
당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요.
|
당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요.
|
||||||
**🍸 가게 정보:**
|
**🍸 가게 정보:**
|
||||||
@ -1688,6 +1733,8 @@ class SnsContentService:
|
|||||||
- 이벤트: {request.eventName or '특별 이벤트'}
|
- 이벤트: {request.eventName or '특별 이벤트'}
|
||||||
- 독자층: {request.target}
|
- 독자층: {request.target}
|
||||||
|
|
||||||
|
{image_tag_usage}
|
||||||
|
|
||||||
**📱 인스타그램 특화 요구사항:**
|
**📱 인스타그램 특화 요구사항:**
|
||||||
- 글 구조: {platform_spec['content_structure']}
|
- 글 구조: {platform_spec['content_structure']}
|
||||||
- 최대 길이: {platform_spec['max_length']}자
|
- 최대 길이: {platform_spec['max_length']}자
|
||||||
@ -1709,9 +1756,20 @@ class SnsContentService:
|
|||||||
1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작
|
1. 첫 문장은 반드시 관심을 끄는 후킹 문장으로 시작
|
||||||
2. 이모티콘을 적절히 활용하여 시각적 재미 추가
|
2. 이모티콘을 적절히 활용하여 시각적 재미 추가
|
||||||
3. 스토리텔링을 통해 감정적 연결 유도
|
3. 스토리텔링을 통해 감정적 연결 유도
|
||||||
4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등)
|
4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시
|
||||||
5. 줄바꿈을 활용하여 가독성 향상
|
5. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 등)
|
||||||
6. 해시태그는 본문과 자연스럽게 연결되도록 배치
|
6. 줄바꿈을 활용하여 가독성 향상
|
||||||
|
7. 해시태그는 본문과 자연스럽게 연결되도록 배치
|
||||||
|
|
||||||
|
**⚠️ 중요한 제약사항:**
|
||||||
|
- 반드시 제공된 {actual_image_count}개의 이미지 개수를 초과하지 마세요
|
||||||
|
- [IMAGE_{actual_image_count}]까지만 사용하세요
|
||||||
|
- 더 많은 이미지 태그를 사용하면 오류가 발생합니다
|
||||||
|
|
||||||
|
**이미지 태그 사용법:**
|
||||||
|
- [IMAGE_1]: 첫 번째 이미지 배치 위치
|
||||||
|
- [IMAGE_2]: 두 번째 이미지 배치 위치
|
||||||
|
- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성
|
||||||
|
|
||||||
**필수 요구사항:**
|
**필수 요구사항:**
|
||||||
{request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'
|
{request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'
|
||||||
@ -1729,6 +1787,9 @@ class SnsContentService:
|
|||||||
category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', [])
|
category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', [])
|
||||||
seo_keywords = platform_spec['seo_keywords']
|
seo_keywords = platform_spec['seo_keywords']
|
||||||
|
|
||||||
|
# 🔥 핵심: 실제 이미지 개수 계산
|
||||||
|
actual_image_count = len(request.images) if request.images else 0
|
||||||
|
|
||||||
# 이미지 배치 정보 추가
|
# 이미지 배치 정보 추가
|
||||||
image_placement_info = ""
|
image_placement_info = ""
|
||||||
if image_placement_plan:
|
if image_placement_plan:
|
||||||
@ -1777,14 +1838,13 @@ class SnsContentService:
|
|||||||
1. 검색자의 궁금증을 해결하는 정보 중심 작성
|
1. 검색자의 궁금증을 해결하는 정보 중심 작성
|
||||||
2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함
|
2. 구체적인 가격, 위치, 운영시간 등 실용 정보 포함
|
||||||
3. 개인적인 경험과 솔직한 후기 작성
|
3. 개인적인 경험과 솔직한 후기 작성
|
||||||
4. 각 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시
|
4. 이미지마다 간단한 설명 문구 추가
|
||||||
5. 이미지마다 간단한 설명 문구 추가
|
5. 지역 정보와 접근성 정보 포함
|
||||||
6. 지역 정보와 접근성 정보 포함
|
|
||||||
|
|
||||||
**이미지 태그 사용법:**
|
**⚠️ 중요한 제약사항:**
|
||||||
- [IMAGE_1]: 첫 번째 이미지 배치 위치
|
- 반드시 제공된 {actual_image_count}개의 이미지 개수를 초과하지 마세요
|
||||||
- [IMAGE_2]: 두 번째 이미지 배치 위치
|
- [IMAGE_{actual_image_count}]까지만 사용하세요
|
||||||
- 각 이미지 태그 다음 줄에 이미지 설명 문구 작성
|
- {actual_image_count}개를 초과하는 [IMAGE_X] 태그는 절대 사용 금지
|
||||||
|
|
||||||
**필수 요구사항:**
|
**필수 요구사항:**
|
||||||
{request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'
|
{request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'
|
||||||
@ -1792,6 +1852,7 @@ class SnsContentService:
|
|||||||
네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요.
|
네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요.
|
||||||
필수 요구사항을 반드시 참고하여 작성해주세요.
|
필수 요구사항을 반드시 참고하여 작성해주세요.
|
||||||
이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요.
|
이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
@ -1811,6 +1872,14 @@ class SnsContentService:
|
|||||||
"""
|
"""
|
||||||
import re
|
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)
|
hashtags = re.findall(r'#[\w가-힣]+', content)
|
||||||
if len(hashtags) > 15:
|
if len(hashtags) > 15:
|
||||||
@ -1867,6 +1936,14 @@ class SnsContentService:
|
|||||||
# 이미지를 콘텐츠 맨 앞에 추가
|
# 이미지를 콘텐츠 맨 앞에 추가
|
||||||
content = images_html_content + content
|
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. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환
|
# 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환
|
||||||
elif request.platform == '네이버 블로그' and image_placement_plan:
|
elif request.platform == '네이버 블로그' and image_placement_plan:
|
||||||
content = self._replace_image_tags_with_html(content, image_placement_plan, request.images)
|
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:
|
external:
|
||||||
store-service:
|
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}
|
timeout: ${STORE_SERVICE_TIMEOUT:5000}
|
||||||
python-ai-service:
|
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}
|
api-key: ${PYTHON_AI_API_KEY:dummy-key}
|
||||||
timeout: ${PYTHON_AI_TIMEOUT:30000}
|
timeout: ${PYTHON_AI_TIMEOUT:30000}
|
||||||
|
|
||||||
@ -70,4 +70,6 @@ info:
|
|||||||
app:
|
app:
|
||||||
name: ${APP_NAME:smarketing-recommend}
|
name: ${APP_NAME:smarketing-recommend}
|
||||||
version: "1.0.0-MVP"
|
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-messaging-eventhubs-checkpointstore-blob:1.19.0'
|
||||||
implementation 'com.azure:azure-identity:1.11.4'
|
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') {
|
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 PIPELINE_ID = "${env.BUILD_NUMBER}"
|
||||||
|
|
||||||
def getImageTag() {
|
def getImageTag() {
|
||||||
@ -12,230 +14,233 @@ podTemplate(
|
|||||||
containers: [
|
containers: [
|
||||||
containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'),
|
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: '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: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
|
||||||
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
|
|
||||||
],
|
],
|
||||||
volumes: [
|
volumes: [
|
||||||
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
|
emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false),
|
||||||
emptyDirVolume(mountPath: '/root/.azure', memory: false),
|
|
||||||
emptyDirVolume(mountPath: '/var/run', memory: false)
|
emptyDirVolume(mountPath: '/var/run', memory: false)
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
node(PIPELINE_ID) {
|
node(PIPELINE_ID) {
|
||||||
def props
|
def props
|
||||||
def imageTag = getImageTag()
|
def imageTag = getImageTag()
|
||||||
def manifest = "deploy.yaml"
|
|
||||||
def namespace
|
|
||||||
def services = ['member', 'store', 'marketing-content', 'ai-recommend']
|
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") {
|
try {
|
||||||
checkout scm
|
stage("Get Source") {
|
||||||
|
checkout scm
|
||||||
// smarketing-java 하위에 있는 설정 파일 읽기
|
|
||||||
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
|
// smarketing-java 하위에 있는 설정 파일 읽기
|
||||||
namespace = "${props.namespace}"
|
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
|
||||||
|
|
||||||
echo "=== Build Information ==="
|
echo "=== Build Information ==="
|
||||||
echo "Services: ${services}"
|
echo "Services: ${services}"
|
||||||
echo "Namespace: ${namespace}"
|
echo "Image Tag: ${imageTag}"
|
||||||
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") {
|
stage("Check Changes") {
|
||||||
container('azure-cli') {
|
script {
|
||||||
withCredentials([azureServicePrincipal('azure-credentials')]) {
|
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 """
|
sh """
|
||||||
echo "=== Azure 로그인 ==="
|
echo "=== smarketing-java 디렉토리로 이동 ==="
|
||||||
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
|
cd smarketing-java
|
||||||
az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66
|
|
||||||
|
|
||||||
echo "=== AKS 인증정보 가져오기 (rg-digitalgarage-02) ==="
|
echo "=== gradlew 권한 설정 ==="
|
||||||
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
|
chmod +x gradlew
|
||||||
|
|
||||||
echo "=== 네임스페이스 생성 ==="
|
echo "=== 전체 서비스 빌드 ==="
|
||||||
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
|
./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 생성 ==="
|
echo "=== 빌드 결과 확인 ==="
|
||||||
kubectl create secret docker-registry acr-secret \\
|
find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar'
|
||||||
--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') {
|
stage('Build & Push Images') {
|
||||||
container('gradle') {
|
container('docker') {
|
||||||
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 """
|
sh """
|
||||||
echo "=== Docker로 ACR 로그인 ==="
|
echo "=== Docker 데몬 시작 대기 ==="
|
||||||
echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin
|
timeout 30 sh -c 'until docker info; do sleep 1; done'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
services.each { service ->
|
// ACR Credential을 Jenkins에서 직접 사용
|
||||||
script {
|
withCredentials([usernamePassword(
|
||||||
def buildDir = "smarketing-java/${service}"
|
credentialsId: 'acr-credentials',
|
||||||
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
|
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 파일명 동적 탐지
|
services.each { service ->
|
||||||
def actualJarFile = sh(
|
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
|
||||||
script: """
|
def deploymentFile = "smarketing/deployments/${service}/${service}-deployment.yaml"
|
||||||
cd ${buildDir}/build/libs
|
|
||||||
ls *.jar | grep -v 'plain.jar' | head -1
|
sh """
|
||||||
""",
|
cd manifest-repo
|
||||||
returnStdout: true
|
|
||||||
).trim()
|
echo "=== ${service} 이미지 태그 업데이트 ==="
|
||||||
|
if [ -f "${deploymentFile}" ]; then
|
||||||
if (!actualJarFile) {
|
# 이미지 태그 업데이트 (sed 사용)
|
||||||
error "${service} JAR 파일을 찾을 수 없습니다"
|
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 """
|
sh """
|
||||||
echo "=== ${service} 이미지 빌드 ==="
|
cd manifest-repo
|
||||||
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}"
|
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') {
|
stage('Trigger ArgoCD Sync') {
|
||||||
container('envsubst') {
|
script {
|
||||||
sh """
|
echo """
|
||||||
echo "=== 환경변수 설정 ==="
|
🎯 CI Pipeline 완료!
|
||||||
export namespace=${namespace}
|
|
||||||
export allowed_origins=${props.allowed_origins}
|
|
||||||
export jwt_secret_key=${props.jwt_secret_key}
|
|
||||||
export postgres_user=${props.postgres_user}
|
|
||||||
export postgres_password=${props.postgres_password}
|
|
||||||
export replicas=${props.replicas}
|
|
||||||
# 리소스 요구사항 조정 (작게)
|
|
||||||
export resources_requests_cpu=100m
|
|
||||||
export resources_requests_memory=128Mi
|
|
||||||
export resources_limits_cpu=500m
|
|
||||||
export resources_limits_memory=512Mi
|
|
||||||
|
|
||||||
# 이미지 경로 환경변수 설정
|
📦 빌드된 이미지들:
|
||||||
export member_image_path=${props.registry}/${props.image_org}/member:${imageTag}
|
${services.collect { "- ${props.registry}/${props.image_org}/${it}:${imageTag}" }.join('\n')}
|
||||||
export store_image_path=${props.registry}/${props.image_org}/store:${imageTag}
|
|
||||||
export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag}
|
|
||||||
export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag}
|
|
||||||
|
|
||||||
echo "=== Manifest 생성 ==="
|
🔄 ArgoCD 동작:
|
||||||
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
|
- ArgoCD가 manifest repository 변경사항을 자동으로 감지합니다
|
||||||
|
- 각 서비스별 Application이 새로운 이미지로 동기화됩니다
|
||||||
echo "=== Generated Manifest File ==="
|
- ArgoCD UI에서 배포 상태를 모니터링하세요
|
||||||
cat smarketing-java/deployment/${manifest}
|
|
||||||
echo "==============================="
|
🌐 ArgoCD UI: [ArgoCD 접속 URL]
|
||||||
"""
|
📁 Manifest Repo: ${MANIFEST_REPO}
|
||||||
|
"""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container('azure-cli') {
|
// 성공 시 처리
|
||||||
sh """
|
echo """
|
||||||
echo "=== 현재 연결된 클러스터 재확인 ==="
|
✅ CI Pipeline 성공!
|
||||||
kubectl config current-context
|
🏷️ 새로운 이미지 태그: ${imageTag}
|
||||||
kubectl cluster-info | head -3
|
🔄 ArgoCD가 자동으로 배포를 시작합니다
|
||||||
|
"""
|
||||||
echo "=== PostgreSQL 서비스 확인 ==="
|
|
||||||
kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요."
|
|
||||||
|
|
||||||
echo "=== Manifest 적용 ==="
|
|
||||||
kubectl apply -f smarketing-java/deployment/${manifest}
|
|
||||||
|
|
||||||
echo "=== 배포 상태 확인 (60초 대기) ==="
|
} catch (Exception e) {
|
||||||
kubectl -n ${namespace} get deployments
|
// 실패 시 처리
|
||||||
kubectl -n ${namespace} get pods
|
echo "❌ CI Pipeline 실패: ${e.getMessage()}"
|
||||||
|
throw e
|
||||||
echo "=== 각 서비스 배포 대기 (60초 timeout) ==="
|
} finally {
|
||||||
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 대기 타임아웃"
|
container('docker') {
|
||||||
timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃"
|
sh 'docker system prune -f || true'
|
||||||
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
|
|
||||||
"""
|
|
||||||
}
|
}
|
||||||
|
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}
|
ALLOWED_ORIGINS: ${allowed_origins}
|
||||||
JPA_DDL_AUTO: update
|
JPA_DDL_AUTO: update
|
||||||
JPA_SHOW_SQL: 'true'
|
JPA_SHOW_SQL: 'true'
|
||||||
# 🔧 강화된 Actuator 설정
|
# Actuator 설정
|
||||||
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: '*'
|
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: '*'
|
||||||
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always
|
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always
|
||||||
MANAGEMENT_ENDPOINT_HEALTH_ENABLED: 'true'
|
MANAGEMENT_ENDPOINT_HEALTH_ENABLED: 'true'
|
||||||
MANAGEMENT_ENDPOINTS_WEB_BASE_PATH: /actuator
|
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
|
apiVersion: v1
|
||||||
@ -26,10 +21,14 @@ metadata:
|
|||||||
name: member-config
|
name: member-config
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
data:
|
data:
|
||||||
POSTGRES_DB: member
|
|
||||||
POSTGRES_HOST: member-postgresql
|
|
||||||
POSTGRES_PORT: '5432'
|
|
||||||
SERVER_PORT: '8081'
|
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
|
apiVersion: v1
|
||||||
@ -38,10 +37,14 @@ metadata:
|
|||||||
name: store-config
|
name: store-config
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
data:
|
data:
|
||||||
POSTGRES_DB: store
|
|
||||||
POSTGRES_HOST: store-postgresql
|
|
||||||
POSTGRES_PORT: '5432'
|
|
||||||
SERVER_PORT: '8082'
|
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
|
apiVersion: v1
|
||||||
@ -50,10 +53,14 @@ metadata:
|
|||||||
name: marketing-content-config
|
name: marketing-content-config
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
data:
|
data:
|
||||||
POSTGRES_DB: marketing_content
|
|
||||||
POSTGRES_HOST: marketing-content-postgresql
|
|
||||||
POSTGRES_PORT: '5432'
|
|
||||||
SERVER_PORT: '8083'
|
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
|
apiVersion: v1
|
||||||
@ -62,10 +69,14 @@ metadata:
|
|||||||
name: ai-recommend-config
|
name: ai-recommend-config
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
data:
|
data:
|
||||||
POSTGRES_DB: ai_recommend
|
|
||||||
POSTGRES_HOST: ai-recommend-postgresql
|
|
||||||
POSTGRES_PORT: '5432'
|
|
||||||
SERVER_PORT: '8084'
|
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
|
# Secrets
|
||||||
@ -87,8 +98,9 @@ metadata:
|
|||||||
stringData:
|
stringData:
|
||||||
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
|
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
|
||||||
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
|
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
|
||||||
POSTGRES_PASSWORD: ${postgres_password}
|
|
||||||
POSTGRES_USER: ${postgres_user}
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
REDIS_PASSWORD: ${redis_password}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -98,8 +110,9 @@ metadata:
|
|||||||
name: store-secret
|
name: store-secret
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
stringData:
|
stringData:
|
||||||
POSTGRES_PASSWORD: ${postgres_password}
|
|
||||||
POSTGRES_USER: ${postgres_user}
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
REDIS_PASSWORD: ${redis_password}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -109,8 +122,9 @@ metadata:
|
|||||||
name: marketing-content-secret
|
name: marketing-content-secret
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
stringData:
|
stringData:
|
||||||
POSTGRES_PASSWORD: ${postgres_password}
|
|
||||||
POSTGRES_USER: ${postgres_user}
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
REDIS_PASSWORD: ${redis_password}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -120,8 +134,9 @@ metadata:
|
|||||||
name: ai-recommend-secret
|
name: ai-recommend-secret
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
stringData:
|
stringData:
|
||||||
POSTGRES_PASSWORD: ${postgres_password}
|
|
||||||
POSTGRES_USER: ${postgres_user}
|
POSTGRES_USER: ${postgres_user}
|
||||||
|
POSTGRES_PASSWORD: ${postgres_password}
|
||||||
|
REDIS_PASSWORD: ${redis_password}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -167,39 +182,6 @@ spec:
|
|||||||
name: common-secret
|
name: common-secret
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: member-secret
|
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
|
apiVersion: apps/v1
|
||||||
@ -243,38 +225,7 @@ spec:
|
|||||||
name: common-secret
|
name: common-secret
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: store-secret
|
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
|
apiVersion: apps/v1
|
||||||
@ -318,38 +269,7 @@ spec:
|
|||||||
name: common-secret
|
name: common-secret
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: marketing-content-secret
|
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
|
apiVersion: apps/v1
|
||||||
@ -393,38 +313,7 @@ spec:
|
|||||||
name: common-secret
|
name: common-secret
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: ai-recommend-secret
|
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
|
# Services
|
||||||
@ -487,14 +376,15 @@ spec:
|
|||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: smarketing-backend
|
name: smarketing-ingress
|
||||||
namespace: ${namespace}
|
namespace: ${namespace}
|
||||||
annotations:
|
annotations:
|
||||||
kubernetes.io/ingress.class: nginx
|
kubernetes.io/ingress.class: nginx
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
rules:
|
rules:
|
||||||
- http:
|
- host: smarketing.20.249.184.228.nip.io
|
||||||
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /api/auth
|
- path: /api/auth
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
@ -524,3 +414,4 @@ spec:
|
|||||||
name: ai-recommend
|
name: ai-recommend
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
|
||||||
@ -8,8 +8,9 @@ registry=acrdigitalgarage02.azurecr.io
|
|||||||
image_org=smarketing
|
image_org=smarketing
|
||||||
|
|
||||||
# Application Settings
|
# Application Settings
|
||||||
|
ingress_host=smarketing.20.249.184.228.nip.io
|
||||||
replicas=1
|
replicas=1
|
||||||
allowed_origins=http://20.249.171.38
|
allowed_origins=http://20.249.154.194
|
||||||
|
|
||||||
# Security Settings
|
# Security Settings
|
||||||
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
|
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 {
|
dependencies {
|
||||||
implementation project(':common')
|
implementation project(':common')
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
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.ContentType;
|
||||||
import com.won.smarketing.content.domain.model.CreationConditions;
|
import com.won.smarketing.content.domain.model.CreationConditions;
|
||||||
import com.won.smarketing.content.domain.model.Platform;
|
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.repository.ContentRepository;
|
||||||
import com.won.smarketing.content.domain.service.AiPosterGenerator;
|
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.PosterContentCreateRequest;
|
||||||
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
|
||||||
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
|
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.util.List;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포스터 콘텐츠 서비스 구현체
|
* 포스터 콘텐츠 서비스 구현체
|
||||||
* 홍보 포스터 생성 및 저장 기능 구현
|
* 홍보 포스터 생성 및 저장 기능 구현
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class PosterContentService implements PosterContentUseCase {
|
public class PosterContentService implements PosterContentUseCase {
|
||||||
|
|
||||||
|
@Value("${azure.storage.container.poster-images:poster-images}")
|
||||||
|
private String posterImageContainer;
|
||||||
|
|
||||||
private final ContentRepository contentRepository;
|
private final ContentRepository contentRepository;
|
||||||
private final AiPosterGenerator aiPosterGenerator;
|
private final AiPosterGenerator aiPosterGenerator;
|
||||||
|
private final BlobStorageService blobStorageService;
|
||||||
|
private final StoreDataProvider storeDataProvider;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포스터 콘텐츠 생성
|
* 포스터 콘텐츠 생성
|
||||||
@ -39,26 +50,24 @@ public class PosterContentService implements PosterContentUseCase {
|
|||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@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);
|
||||||
|
|
||||||
// 생성 조건 정보 구성
|
// 2. AI 요청
|
||||||
CreationConditions conditions = CreationConditions.builder()
|
String generatedPoster = aiPosterGenerator.generatePoster(request, storeWithMenuData);
|
||||||
.category(request.getCategory())
|
|
||||||
.requirement(request.getRequirement())
|
|
||||||
.eventName(request.getEventName())
|
|
||||||
.startDate(request.getStartDate())
|
|
||||||
.endDate(request.getEndDate())
|
|
||||||
.photoStyle(request.getPhotoStyle())
|
|
||||||
.build();
|
|
||||||
|
|
||||||
return PosterContentCreateResponse.builder()
|
return PosterContentCreateResponse.builder()
|
||||||
.contentId(null) // 임시 생성이므로 ID 없음
|
.contentId(null) // 임시 생성이므로 ID 없음
|
||||||
.contentType(ContentType.POSTER.name())
|
.contentType(ContentType.POSTER.name())
|
||||||
.title(request.getTitle())
|
.title(request.getTitle())
|
||||||
.posterImage(generatedPoster)
|
.content(generatedPoster)
|
||||||
.posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함)
|
|
||||||
.status(ContentStatus.DRAFT.name())
|
.status(ContentStatus.DRAFT.name())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@ -68,7 +77,6 @@ public class PosterContentService implements PosterContentUseCase {
|
|||||||
*
|
*
|
||||||
* @param request 포스터 콘텐츠 저장 요청
|
* @param request 포스터 콘텐츠 저장 요청
|
||||||
*/
|
*/
|
||||||
@Override
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void savePosterContent(PosterContentSaveRequest request) {
|
public void savePosterContent(PosterContentSaveRequest request) {
|
||||||
// 생성 조건 구성
|
// 생성 조건 구성
|
||||||
@ -96,4 +104,11 @@ public class PosterContentService implements PosterContentUseCase {
|
|||||||
// 저장
|
// 저장
|
||||||
contentRepository.save(content);
|
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.SnsContentCreateResponse;
|
||||||
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
|
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@ -34,6 +35,9 @@ public class SnsContentService implements SnsContentUseCase {
|
|||||||
private final AiContentGenerator aiContentGenerator;
|
private final AiContentGenerator aiContentGenerator;
|
||||||
private final BlobStorageService blobStorageService;
|
private final BlobStorageService blobStorageService;
|
||||||
|
|
||||||
|
@Value("${azure.storage.container.poster-images:content-images}")
|
||||||
|
private String contentImageContainer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SNS 콘텐츠 생성
|
* SNS 콘텐츠 생성
|
||||||
*
|
*
|
||||||
@ -44,8 +48,10 @@ public class SnsContentService implements SnsContentUseCase {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List<MultipartFile> files) {
|
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List<MultipartFile> files) {
|
||||||
//파일들 주소 가져옴
|
//파일들 주소 가져옴
|
||||||
List<String> urls = blobStorageService.uploadImage(files);
|
if(files != null) {
|
||||||
request.setImages(urls);
|
List<String> urls = blobStorageService.uploadImage(files, contentImageContainer);
|
||||||
|
request.setImages(urls);
|
||||||
|
}
|
||||||
|
|
||||||
// AI를 사용하여 SNS 콘텐츠 생성
|
// AI를 사용하여 SNS 콘텐츠 생성
|
||||||
String content = aiContentGenerator.generateSnsContent(request);
|
String content = aiContentGenerator.generateSnsContent(request);
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
|
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
|
||||||
package com.won.smarketing.content.application.usecase;
|
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.PosterContentCreateRequest;
|
||||||
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
|
||||||
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
|
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 포스터 콘텐츠 관련 UseCase 인터페이스
|
* 포스터 콘텐츠 관련 UseCase 인터페이스
|
||||||
@ -16,7 +20,7 @@ public interface PosterContentUseCase {
|
|||||||
* @param request 포스터 콘텐츠 생성 요청
|
* @param request 포스터 콘텐츠 생성 요청
|
||||||
* @return 포스터 콘텐츠 생성 응답
|
* @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;
|
package com.won.smarketing.content.config;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@ -20,8 +19,8 @@ public class WebClientConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public WebClient webClient() {
|
public WebClient webClient() {
|
||||||
HttpClient httpClient = HttpClient.create()
|
HttpClient httpClient = HttpClient.create()
|
||||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 50000)
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 15000) // 연결 타임아웃: 15초
|
||||||
.responseTimeout(Duration.ofMillis(300000));
|
.responseTimeout(Duration.ofMinutes(5)); // 응답 타임아웃: 5분
|
||||||
|
|
||||||
return WebClient.builder()
|
return WebClient.builder()
|
||||||
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
.clientConnector(new ReactorClientHttpConnector(httpClient))
|
||||||
|
|||||||
@ -27,42 +27,37 @@ import java.util.List;
|
|||||||
@Builder
|
@Builder
|
||||||
public class Content {
|
public class Content {
|
||||||
|
|
||||||
// ==================== 기본키 및 식별자 ====================
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@Column(name = "content_id")
|
@Column(name = "content_id")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
// ==================== 콘텐츠 분류 ====================
|
|
||||||
private ContentType contentType;
|
private ContentType contentType;
|
||||||
|
|
||||||
private Platform platform;
|
private Platform platform;
|
||||||
|
|
||||||
// ==================== 콘텐츠 내용 ====================
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
// ==================== 멀티미디어 및 메타데이터 ====================
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> hashtags = new ArrayList<>();
|
private List<String> hashtags = new ArrayList<>();
|
||||||
|
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> images = new ArrayList<>();
|
private List<String> images = new ArrayList<>();
|
||||||
|
|
||||||
// ==================== 상태 관리 ====================
|
|
||||||
private ContentStatus status;
|
private ContentStatus status;
|
||||||
|
|
||||||
// ==================== 생성 조건 ====================
|
|
||||||
private CreationConditions creationConditions;
|
private CreationConditions creationConditions;
|
||||||
|
|
||||||
// ==================== 매장 정보 ====================
|
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
|
|
||||||
// ==================== 프로모션 기간 ====================
|
|
||||||
private LocalDateTime promotionStartDate;
|
private LocalDateTime promotionStartDate;
|
||||||
|
|
||||||
private LocalDateTime promotionEndDate;
|
private LocalDateTime promotionEndDate;
|
||||||
|
|
||||||
// ==================== 메타데이터 ====================
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
private LocalDateTime updatedAt;
|
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) {
|
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 id;
|
||||||
private String category;
|
private String category;
|
||||||
private String requirement;
|
private String requirement;
|
||||||
// private String toneAndManner;
|
|
||||||
// private String emotionIntensity;
|
|
||||||
private String storeName;
|
private String storeName;
|
||||||
private String storeType;
|
private String storeType;
|
||||||
private String target;
|
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;
|
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 com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -16,5 +17,5 @@ public interface AiPosterGenerator {
|
|||||||
* @param request 포스터 생성 요청
|
* @param request 포스터 생성 요청
|
||||||
* @return 생성된 포스터 이미지 URL
|
* @return 생성된 포스터 이미지 URL
|
||||||
*/
|
*/
|
||||||
String generatePoster(PosterContentCreateRequest request);
|
String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ public interface BlobStorageService {
|
|||||||
* @param file 업로드할 파일
|
* @param file 업로드할 파일
|
||||||
* @return 업로드된 파일의 URL
|
* @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;
|
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
|
@Value("${azure.storage.max-file-size:10485760}") // 10MB
|
||||||
private long maxFileSize;
|
private long maxFileSize;
|
||||||
|
|
||||||
@ -60,7 +54,7 @@ public class BlobStorageServiceImpl implements BlobStorageService {
|
|||||||
* @return 업로드된 파일의 URL
|
* @return 업로드된 파일의 URL
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public List<String> uploadImage(List<MultipartFile> files) {
|
public List<String> uploadImage(List<MultipartFile> files, String containerName) {
|
||||||
// 파일 유효성 검증
|
// 파일 유효성 검증
|
||||||
validateImageFile(files);
|
validateImageFile(files);
|
||||||
List<String> urls = new ArrayList<>();
|
List<String> urls = new ArrayList<>();
|
||||||
@ -70,10 +64,10 @@ public class BlobStorageServiceImpl implements BlobStorageService {
|
|||||||
for(MultipartFile file : files) {
|
for(MultipartFile file : files) {
|
||||||
String fileName = generateMenuImageFileName(file.getOriginalFilename());
|
String fileName = generateMenuImageFileName(file.getOriginalFilename());
|
||||||
|
|
||||||
ensureContainerExists(posterImageContainer);
|
ensureContainerExists(containerName);
|
||||||
|
|
||||||
// Blob 클라이언트 생성
|
// Blob 클라이언트 생성
|
||||||
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(posterImageContainer);
|
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName);
|
||||||
BlobClient blobClient = containerClient.getBlobClient(fileName);
|
BlobClient blobClient = containerClient.getBlobClient(fileName);
|
||||||
|
|
||||||
// 파일 업로드 (간단한 방식)
|
// 파일 업로드 (간단한 방식)
|
||||||
@ -158,12 +152,12 @@ public class BlobStorageServiceImpl implements BlobStorageService {
|
|||||||
* @param files 검증할 파일
|
* @param files 검증할 파일
|
||||||
*/
|
*/
|
||||||
private void validateImageFile(List<MultipartFile> files) {
|
private void validateImageFile(List<MultipartFile> files) {
|
||||||
for (MultipartFile file : files) {
|
// 파일 존재 여부 확인
|
||||||
// 파일 존재 여부 확인
|
if (files == null || files.isEmpty()) {
|
||||||
if (file == null || file.isEmpty()) {
|
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
|
||||||
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
// 파일 크기 확인
|
// 파일 크기 확인
|
||||||
if (file.getSize() > maxFileSize) {
|
if (file.getSize() > maxFileSize) {
|
||||||
throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED);
|
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;
|
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.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
|
||||||
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
|
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@ -11,7 +14,9 @@ import org.springframework.web.reactive.function.client.WebClient;
|
|||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude AI를 활용한 포스터 생성 구현체
|
* Claude AI를 활용한 포스터 생성 구현체
|
||||||
@ -34,12 +39,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
|
|||||||
* @return 생성된 포스터 이미지 URL
|
* @return 생성된 포스터 이미지 URL
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public String generatePoster(PosterContentCreateRequest request) {
|
public String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
|
||||||
try {
|
try {
|
||||||
log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl);
|
log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl);
|
||||||
|
|
||||||
// 요청 데이터 구성
|
// 요청 데이터 구성
|
||||||
Map<String, Object> requestBody = buildRequestBody(request);
|
Map<String, Object> requestBody = buildRequestBody(request, storeWithMenuData);
|
||||||
|
|
||||||
log.debug("포스터 생성 요청 데이터: {}", requestBody);
|
log.debug("포스터 생성 요청 데이터: {}", requestBody);
|
||||||
|
|
||||||
@ -51,7 +56,7 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
|
|||||||
.bodyValue(requestBody)
|
.bodyValue(requestBody)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.bodyToMono(Map.class)
|
.bodyToMono(Map.class)
|
||||||
.timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음
|
.timeout(Duration.ofSeconds(90))
|
||||||
.block();
|
.block();
|
||||||
|
|
||||||
// 응답에서 content(이미지 URL) 추출
|
// 응답에서 content(이미지 URL) 추출
|
||||||
@ -75,9 +80,32 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
|
|||||||
* Python 서비스의 PosterContentGetRequest 모델에 맞춤
|
* Python 서비스의 PosterContentGetRequest 모델에 맞춤
|
||||||
* 카테고리,
|
* 카테고리,
|
||||||
*/
|
*/
|
||||||
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request) {
|
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
|
||||||
Map<String, Object> requestBody = new HashMap<>();
|
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("title", request.getTitle());
|
||||||
requestBody.put("category", request.getCategory());
|
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 com.won.smarketing.content.presentation.dto.*;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -26,17 +27,16 @@ import java.util.List;
|
|||||||
* SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공
|
* SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공
|
||||||
*/
|
*/
|
||||||
@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API")
|
@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API")
|
||||||
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/content")
|
@RequestMapping("/api/content")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ContentController {
|
public class ContentController {
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
private final SnsContentUseCase snsContentUseCase;
|
private final SnsContentUseCase snsContentUseCase;
|
||||||
private final PosterContentUseCase posterContentUseCase;
|
private final PosterContentUseCase posterContentUseCase;
|
||||||
private final ContentQueryUseCase contentQueryUseCase;
|
private final ContentQueryUseCase contentQueryUseCase;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SNS 게시물 생성
|
* SNS 게시물 생성
|
||||||
@ -46,7 +46,7 @@ public class ContentController {
|
|||||||
@Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.")
|
@Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.")
|
||||||
@PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestPart("request") String requestJson,
|
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);
|
SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class);
|
||||||
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images);
|
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images);
|
||||||
return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다."));
|
return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다."));
|
||||||
@ -72,15 +72,22 @@ public class ContentController {
|
|||||||
* @return 생성된 포스터 콘텐츠 정보
|
* @return 생성된 포스터 콘텐츠 정보
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
|
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
|
||||||
@PostMapping("/poster/generate")
|
@PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) {
|
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(
|
||||||
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request);
|
@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, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
|
return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 홍보 포스터 저장
|
* 홍보 포스터 저장
|
||||||
*
|
*
|
||||||
* @param request 포스터 콘텐츠 저장 요청
|
* @param request 포스터 콘텐츠 저장 요청
|
||||||
* @return 저장 성공 응답
|
* @return 저장 성공 응답
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -50,9 +50,7 @@ public class PosterContentCreateRequest {
|
|||||||
@Schema(description = "이미지 스타일", example = "모던")
|
@Schema(description = "이미지 스타일", example = "모던")
|
||||||
private String imageStyle;
|
private String imageStyle;
|
||||||
|
|
||||||
@Schema(description = "업로드된 이미지 URL 목록", required = true)
|
@Schema(description = "업로드된 이미지 URL 목록")
|
||||||
@NotNull(message = "이미지는 1개 이상 필수입니다")
|
|
||||||
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
|
|
||||||
private List<String> images;
|
private List<String> images;
|
||||||
|
|
||||||
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
||||||
|
|||||||
@ -31,19 +31,9 @@ public class PosterContentCreateResponse {
|
|||||||
@Schema(description = "생성된 포스터 타입")
|
@Schema(description = "생성된 포스터 타입")
|
||||||
private String contentType;
|
private String contentType;
|
||||||
|
|
||||||
@Schema(description = "포스터 이미지 URL")
|
|
||||||
private String posterImage;
|
|
||||||
|
|
||||||
@Schema(description = "원본 이미지 URL 목록")
|
|
||||||
private List<String> originalImages;
|
|
||||||
|
|
||||||
@Schema(description = "이미지 스타일", example = "모던")
|
@Schema(description = "이미지 스타일", example = "모던")
|
||||||
private String imageStyle;
|
private String imageStyle;
|
||||||
|
|
||||||
@Schema(description = "생성 상태", example = "DRAFT")
|
@Schema(description = "생성 상태", example = "DRAFT")
|
||||||
private String status;
|
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;
|
package com.won.smarketing.content.presentation.dto;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
@ -19,12 +17,7 @@ import java.util.List;
|
|||||||
@Schema(description = "포스터 콘텐츠 저장 요청")
|
@Schema(description = "포스터 콘텐츠 저장 요청")
|
||||||
public class PosterContentSaveRequest {
|
public class PosterContentSaveRequest {
|
||||||
|
|
||||||
// @Schema(description = "콘텐츠 ID", example = "1", required = true)
|
@Schema(description = "매장 ID", example = "1")
|
||||||
// @NotNull(message = "콘텐츠 ID는 필수입니다")
|
|
||||||
// private Long contentId;
|
|
||||||
|
|
||||||
@Schema(description = "매장 ID", example = "1", required = true)
|
|
||||||
@NotNull(message = "매장 ID는 필수입니다")
|
|
||||||
private Long storeId;
|
private Long storeId;
|
||||||
|
|
||||||
@Schema(description = "제목", example = "특별 이벤트 안내")
|
@Schema(description = "제목", example = "특별 이벤트 안내")
|
||||||
@ -36,22 +29,12 @@ public class PosterContentSaveRequest {
|
|||||||
@Schema(description = "선택된 포스터 이미지 URL")
|
@Schema(description = "선택된 포스터 이미지 URL")
|
||||||
private List<String> images;
|
private List<String> images;
|
||||||
|
|
||||||
@Schema(description = "발행 상태", example = "PUBLISHED")
|
|
||||||
private String status;
|
|
||||||
|
|
||||||
// CreationConditions에 필요한 필드들
|
|
||||||
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
|
||||||
private String category;
|
private String category;
|
||||||
|
|
||||||
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
|
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
|
||||||
private String requirement;
|
private String requirement;
|
||||||
|
|
||||||
@Schema(description = "톤앤매너", example = "전문적")
|
|
||||||
private String toneAndManner;
|
|
||||||
|
|
||||||
@Schema(description = "감정 강도", example = "보통")
|
|
||||||
private String emotionIntensity;
|
|
||||||
|
|
||||||
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
|
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
|
||||||
private String eventName;
|
private String eventName;
|
||||||
|
|
||||||
|
|||||||
@ -68,18 +68,6 @@ public class SnsContentCreateRequest {
|
|||||||
@Schema(description = "콘텐츠 타입", example = "SNS 게시물")
|
@Schema(description = "콘텐츠 타입", example = "SNS 게시물")
|
||||||
private String contentType;
|
private String contentType;
|
||||||
|
|
||||||
// @Schema(description = "톤앤매너",
|
|
||||||
// example = "친근함",
|
|
||||||
// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
|
|
||||||
// private String toneAndManner;
|
|
||||||
|
|
||||||
// @Schema(description = "감정 강도",
|
|
||||||
// example = "보통",
|
|
||||||
// allowableValues = {"약함", "보통", "강함"})
|
|
||||||
// private String emotionIntensity;
|
|
||||||
|
|
||||||
// ==================== 이벤트 정보 ====================
|
|
||||||
|
|
||||||
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
|
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
|
||||||
example = "신메뉴 출시 이벤트")
|
example = "신메뉴 출시 이벤트")
|
||||||
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")
|
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")
|
||||||
|
|||||||
@ -37,6 +37,10 @@ logging:
|
|||||||
external:
|
external:
|
||||||
ai-service:
|
ai-service:
|
||||||
base-url: ${AI_SERVICE_BASE_URL:http://20.249.113.247:5001}
|
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:
|
azure:
|
||||||
storage:
|
storage:
|
||||||
account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02}
|
account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02}
|
||||||
@ -67,4 +71,7 @@ info:
|
|||||||
app:
|
app:
|
||||||
name: ${APP_NAME:smarketing-content}
|
name: ${APP_NAME:smarketing-content}
|
||||||
version: "1.0.0-MVP"
|
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 com.won.smarketing.common.security.JwtAuthenticationFilter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@ -25,10 +26,13 @@ import java.util.Arrays;
|
|||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig {
|
public class SecurityConfig
|
||||||
|
{
|
||||||
|
|
||||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
@Value("${allowed-origins}")
|
||||||
|
private String allowedOrigins;
|
||||||
/**
|
/**
|
||||||
* Spring Security 필터 체인 설정
|
* Spring Security 필터 체인 설정
|
||||||
*
|
*
|
||||||
@ -43,9 +47,10 @@ public class SecurityConfig {
|
|||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.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/**",
|
"/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()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
@ -71,7 +76,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public CorsConfigurationSource corsConfigurationSource() {
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
|
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
|
||||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
configuration.setAllowedHeaders(Arrays.asList("*"));
|
configuration.setAllowedHeaders(Arrays.asList("*"));
|
||||||
configuration.setAllowCredentials(true);
|
configuration.setAllowCredentials(true);
|
||||||
@ -53,4 +53,6 @@ info:
|
|||||||
app:
|
app:
|
||||||
name: ${APP_NAME:smarketing-member}
|
name: ${APP_NAME:smarketing-member}
|
||||||
version: "1.0.0-MVP"
|
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 {
|
dependencies {
|
||||||
implementation project(':common')
|
implementation project(':common')
|
||||||
runtimeOnly 'com.mysql:mysql-connector-j'
|
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")
|
@Schema(description = "좌석 수", example = "20")
|
||||||
private Integer seatCount;
|
private Integer seatCount;
|
||||||
|
|
||||||
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
|
@Schema(description = "SNS 계정 정보", example = "@mystore")
|
||||||
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
|
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
|
||||||
private String instaAccounts;
|
private String instaAccounts;
|
||||||
|
|
||||||
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
|
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
|
||||||
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
|
@Schema(description = "블로그 계정 정보", example = "mystore")
|
||||||
private String blogAccounts;
|
private String blogAccounts;
|
||||||
|
|
||||||
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
|
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
|
||||||
|
|||||||
@ -47,10 +47,10 @@ public class StoreResponse {
|
|||||||
@Schema(description = "좌석 수", example = "20")
|
@Schema(description = "좌석 수", example = "20")
|
||||||
private Integer seatCount;
|
private Integer seatCount;
|
||||||
|
|
||||||
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
|
@Schema(description = "블로그 계정 정보", example = "mystore")
|
||||||
private String blogAccounts;
|
private String blogAccounts;
|
||||||
|
|
||||||
@Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
|
@Schema(description = "인스타 계정 정보", example = "@mystore")
|
||||||
private String instaAccounts;
|
private String instaAccounts;
|
||||||
|
|
||||||
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
|
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
|
||||||
|
|||||||
@ -43,11 +43,11 @@ public class StoreUpdateRequest {
|
|||||||
@Schema(description = "좌석 수", example = "20")
|
@Schema(description = "좌석 수", example = "20")
|
||||||
private Integer seatCount;
|
private Integer seatCount;
|
||||||
|
|
||||||
@Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
|
@Schema(description = "인스타 계정 정보", example = "@mystore")
|
||||||
@Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다")
|
@Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다")
|
||||||
private String instaAccounts;
|
private String instaAccounts;
|
||||||
|
|
||||||
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
|
@Schema(description = "블로그 계정 정보", example = "mystore")
|
||||||
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
|
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
|
||||||
private String blogAccounts;
|
private String blogAccounts;
|
||||||
|
|
||||||
|
|||||||
@ -68,4 +68,6 @@ info:
|
|||||||
app:
|
app:
|
||||||
name: ${APP_NAME:smarketing-content}
|
name: ${APP_NAME:smarketing-content}
|
||||||
version: "1.0.0-MVP"
|
version: "1.0.0-MVP"
|
||||||
description: "AI 마케팅 서비스 MVP - content"
|
description: "AI 마케팅 서비스 MVP - content"
|
||||||
|
|
||||||
|
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
|
||||||