This commit is contained in:
OhSeongRak 2025-06-20 10:33:02 +09:00
commit 92f1e16a69
12 changed files with 1128 additions and 565 deletions

424
deployment/Jenkinsfile vendored
View File

@ -1,4 +1,5 @@
// deployment/Jenkinsfile
// deployment/Jenkinsfile_ArgoCD
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
@ -13,148 +14,327 @@ podTemplate(
containers: [
containerTemplate(name: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
],
volumes: [
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
// Manifest Repository 설정
def MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git'
def MANIFEST_CREDENTIAL_ID = 'github-credentials-smarketing'
stage("Get Source") {
checkout scm
// 환경변수 파일 확인 및 읽기
if (!fileExists('deployment/deploy_env_vars')) {
error "deployment/deploy_env_vars 파일이 없습니다!"
try {
stage("Get Source") {
checkout scm
// 환경변수 파일 확인 및 읽기
if (!fileExists('deployment/deploy_env_vars')) {
error "deployment/deploy_env_vars 파일이 없습니다!"
}
props = readProperties file: "deployment/deploy_env_vars"
// 필수 환경변수 검증
if (!props.registry || !props.image_org || !props.namespace) {
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
}
echo "=== Build Information ==="
echo "Service: smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
echo "Namespace: ${props.namespace}"
}
props = readProperties file: "deployment/deploy_env_vars"
namespace = "${props.namespace}"
// 필수 환경변수 검증
if (!props.registry || !props.image_org || !props.namespace) {
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
}
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
"""
stage("Check Changes") {
script {
def changes = sh(
script: "git diff --name-only HEAD~1 HEAD",
returnStdout: true
).trim()
if (!changes.contains("src/") && !changes.contains("public/") && !changes.contains("package")) {
echo "No significant frontend changes detected, skipping build"
currentBuild.result = 'SUCCESS'
error("Stopping pipeline - no frontend changes detected")
}
echo "Frontend changes detected, proceeding with build"
}
}
}
stage('Build & Push 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'
)]) {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
stage('Build & Push Frontend Image') {
container('podman') {
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
sh """
echo "=========================================="
echo "Building smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Image Path: ${imagePath}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg VUE_APP_AUTH_URL="${props.auth_url}" \\
--build-arg VUE_APP_MEMBER_URL="${props.member_url}" \\
--build-arg VUE_APP_STORE_URL="${props.store_url}" \\
--build-arg VUE_APP_MENU_URL="${props.menu_url}" \\
--build-arg VUE_APP_SALES_URL="${props.sales_url}" \\
--build-arg VUE_APP_CONTENT_URL="${props.content_url}" \\
--build-arg VUE_APP_RECOMMEND_URL="${props.recommend_url}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${props.export_port}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${imagePath} .
# 이미지 푸시
podman push ${imagePath}
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
echo "Image pushed successfully: ${imagePath}"
sh """
echo "=========================================="
echo "Building smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Image Path: ${imagePath}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg VUE_APP_AUTH_URL="${props.auth_url}" \\
--build-arg VUE_APP_MEMBER_URL="${props.member_url}" \\
--build-arg VUE_APP_STORE_URL="${props.store_url}" \\
--build-arg VUE_APP_MENU_URL="${props.menu_url}" \\
--build-arg VUE_APP_SALES_URL="${props.sales_url}" \\
--build-arg VUE_APP_CONTENT_URL="${props.content_url}" \\
--build-arg VUE_APP_RECOMMEND_URL="${props.recommend_url}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${props.export_port}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${imagePath} .
# 이미지 푸시
podman push ${imagePath}
echo "✅ Frontend image pushed successfully: ${imagePath}"
"""
}
}
}
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-frontend:${imageTag}"
def deploymentFile = "smarketing-frontend/deployments/frontend-deployment.yaml"
sh """
cd manifest-repo
echo "=== smarketing-frontend 이미지 태그 업데이트 ==="
if [ -f "${deploymentFile}" ]; then
# 이미지 태그 업데이트 (sed 사용)
sed -i 's|image: ${props.registry}/${props.image_org}/smarketing-frontend:.*|image: ${fullImageName}|g' "${deploymentFile}"
echo "Updated ${deploymentFile} with new image: ${fullImageName}"
# 변경사항 확인
echo "=== 변경된 내용 확인 ==="
grep "image: ${props.registry}/${props.image_org}/smarketing-frontend" "${deploymentFile}" || echo "이미지 태그 업데이트 확인 실패"
else
echo "Warning: ${deploymentFile} not found"
echo "Creating manifest directory structure..."
# 기본 구조 생성
mkdir -p smarketing-frontend/deployments
# 기본 deployment 파일 생성
cat > "${deploymentFile}" << EOF
---
apiVersion: v1
kind: ConfigMap
metadata:
name: smarketing-frontend-config
namespace: ${props.namespace}
data:
runtime-env.js: |
window.__runtime_config__ = {
AUTH_URL: '${props.auth_url}',
MEMBER_URL: '${props.member_url}',
STORE_URL: '${props.store_url}',
MENU_URL: '${props.menu_url}',
SALES_URL: '${props.sales_url}',
CONTENT_URL: '${props.content_url}',
RECOMMEND_URL: '${props.recommend_url}',
GATEWAY_URL: 'http://${props.ingress_host}',
ENV: 'production',
DEBUG: false
};
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing-frontend
namespace: ${props.namespace}
labels:
app: smarketing-frontend
spec:
replicas: ${props.replicas}
selector:
matchLabels:
app: smarketing-frontend
template:
metadata:
labels:
app: smarketing-frontend
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing-frontend
image: ${fullImageName}
imagePullPolicy: Always
ports:
- containerPort: ${props.export_port}
resources:
requests:
cpu: ${props.resources_requests_cpu}
memory: ${props.resources_requests_memory}
limits:
cpu: ${props.resources_limits_cpu}
memory: ${props.resources_limits_memory}
volumeMounts:
- name: runtime-config
mountPath: /usr/share/nginx/html/runtime-env.js
subPath: runtime-env.js
livenessProbe:
httpGet:
path: /health
port: ${props.export_port}
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: ${props.export_port}
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: runtime-config
configMap:
name: smarketing-frontend-config
---
apiVersion: v1
kind: Service
metadata:
name: smarketing-frontend-service
namespace: ${props.namespace}
labels:
app: smarketing-frontend
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: ${props.export_port}
protocol: TCP
name: http
selector:
app: smarketing-frontend
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-frontend-ingress
namespace: ${props.namespace}
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
nginx.ingress.kubernetes.io/ssl-redirect: "false"
spec:
rules:
- host: ${props.ingress_host}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: smarketing-frontend-service
port:
number: 80
EOF
echo "Created basic frontend-deployment.yaml"
fi
"""
sh """
cd manifest-repo
echo "=== Git 변경사항 확인 ==="
git status
git diff
# 변경사항이 있으면 커밋 및 푸시
if [ -n "\$(git status --porcelain)" ]; then
git add .
git commit -m "Update smarketing-frontend 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 """
🎯 Frontend CI Pipeline 완료!
📦 빌드된 이미지:
- ${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}
🔄 ArgoCD 동작:
- ArgoCD가 manifest repository 변경사항을 자동으로 감지합니다
- smarketing-frontend Application이 새로운 이미지로 동기화됩니다
- ArgoCD UI에서 배포 상태를 모니터링하세요
🌐 ArgoCD UI: [ArgoCD 접속 URL]
📁 Manifest Repo: ${MANIFEST_REPO}
"""
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
export namespace=${namespace}
export smarketing_frontend_image_path=${imagePath}
export replicas=${props.replicas}
export export_port=${props.export_port}
export ingress_host=${props.ingress_host}
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}
# API URLs도 export (혹시 사용할 수도 있으니)
export auth_url=${props.auth_url}
export member_url=${props.member_url}
export store_url=${props.store_url}
export menu_url=${props.menu_url}
export sales_url=${props.sales_url}
export content_url=${props.content_url}
export recommend_url=${props.recommend_url}
echo "=== 환경변수 확인 ==="
echo "namespace: \$namespace"
echo "ingress_host: \$ingress_host"
echo "export_port: \$export_port"
echo "========================="
envsubst < deployment/${manifest}.template > deployment/${manifest}
echo "Generated manifest file:"
cat deployment/${manifest}
"""
}
// 성공 시 처리
echo """
✅ Frontend CI Pipeline 성공!
🏷️ 새로운 이미지 태그: ${imageTag}
🔄 ArgoCD가 자동으로 배포를 시작합니다
"""
container('azure-cli') {
sh """
kubectl apply -f deployment/${manifest}
echo "Waiting for deployment to be ready..."
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing-frontend --timeout=300s
echo "Deployment completed successfully!"
kubectl -n ${namespace} get pods -l app=smarketing-frontend
kubectl -n ${namespace} get svc smarketing-frontend-service
kubectl -n ${namespace} get ingress
"""
} catch (Exception e) {
// 실패 시 처리
echo "❌ Frontend CI Pipeline 실패: ${e.getMessage()}"
throw e
} finally {
// 정리 작업 (항상 실행)
container('podman') {
sh 'podman system prune -f || true'
}
sh 'rm -rf manifest-repo || true'
}
}
}
}

View File

@ -1,136 +0,0 @@
pipeline {
agent {
kubernetes {
yaml """
apiVersion: v1
kind: Pod
spec:
containers:
- name: podman
image: quay.io/podman/stable:latest
command:
- cat
tty: true
securityContext:
privileged: true
- name: git
image: alpine/git:latest
command:
- cat
tty: true
"""
}
}
environment {
imageTag = sh(script: "echo ${BUILD_NUMBER}", returnStdout: true).trim()
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Load Deploy Variables') {
steps {
script {
// deploy_env_vars 파일에서 환경변수 로드
if (fileExists('deploy_env_vars')) {
def envVars = readFile('deploy_env_vars').trim()
envVars.split('\n').each { line ->
if (line.contains('=')) {
def (key, value) = line.split('=', 2)
env."${key}" = value
echo "Loaded: ${key} = ${value}"
}
}
} else {
error "deploy_env_vars 파일이 없습니다!"
}
// 이미지 경로 설정
env.imagePath = "${env.REGISTRY}/${env.IMAGE_ORG}/smarketing-frontend:${imageTag}"
echo "Image Path: ${env.imagePath}"
}
}
}
stage('Build & Push Image') {
steps {
container('podman') {
script {
sh """
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg REACT_APP_AUTH_URL="${env.AUTH_URL}" \\
--build-arg REACT_APP_MEMBER_URL="${env.MEMBER_URL}" \\
--build-arg REACT_APP_STORE_URL="${env.STORE_URL}" \\
--build-arg REACT_APP_CONTENT_URL="${env.CONTENT_URL}" \\
--build-arg REACT_APP_RECOMMEND_URL="${env.RECOMMEND_URL}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${env.EXPORT_PORT}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${env.imagePath} .
podman push ${env.imagePath}
"""
}
}
}
}
stage('Update Manifest Repository') {
steps {
container('git') {
withCredentials([usernamePassword(
credentialsId: 'github-credentials-${env.TEAMID}',
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_PASSWORD'
)]) {
sh """
# Git 설정
git config --global user.name "Jenkins"
git config --global user.email "jenkins@company.com"
# Manifest Repository Clone
git clone https://\$GIT_USERNAME:\$GIT_PASSWORD@github.com/${env.GITHUB_ORG}/smarketing-manifest.git
cd smarketing-manifest
# Frontend 이미지 태그 업데이트
echo "Updating smarketing-frontend deployment with image tag: ${imageTag}"
if [ -f "smarketing-frontend/deployment.yaml" ]; then
# 기존 이미지 태그를 새 태그로 교체
sed -i "s|image: ${env.REGISTRY}/${env.IMAGE_ORG}/smarketing-frontend:.*|image: ${env.imagePath}|g" smarketing-frontend/deployment.yaml
echo "Updated frontend deployment file:"
cat smarketing-frontend/deployment.yaml | grep "image:"
else
echo "Warning: Frontend deployment file not found"
fi
# 변경사항 커밋 및 푸시
git add .
git commit -m "Update smarketing-frontend image tag to ${imageTag}" || echo "No changes to commit"
git push origin main
"""
}
}
}
}
}
post {
always {
cleanWs()
}
success {
echo "✅ smarketing-frontend 이미지 빌드 및 Manifest 업데이트가 완료되었습니다!"
}
failure {
echo "❌ smarketing-frontend CI/CD 파이프라인 중 오류가 발생했습니다."
}
}
}

View File

@ -0,0 +1,160 @@
// deployment/Jenkinsfile
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: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'),
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: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
stage("Get Source") {
checkout scm
// 환경변수 파일 확인 및 읽기
if (!fileExists('deployment/deploy_env_vars')) {
error "deployment/deploy_env_vars 파일이 없습니다!"
}
props = readProperties file: "deployment/deploy_env_vars"
namespace = "${props.namespace}"
// 필수 환경변수 검증
if (!props.registry || !props.image_org || !props.namespace) {
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
}
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
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 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'
)]) {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
echo "=========================================="
echo "Building smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Image Path: ${imagePath}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg VUE_APP_AUTH_URL="${props.auth_url}" \\
--build-arg VUE_APP_MEMBER_URL="${props.member_url}" \\
--build-arg VUE_APP_STORE_URL="${props.store_url}" \\
--build-arg VUE_APP_MENU_URL="${props.menu_url}" \\
--build-arg VUE_APP_SALES_URL="${props.sales_url}" \\
--build-arg VUE_APP_CONTENT_URL="${props.content_url}" \\
--build-arg VUE_APP_RECOMMEND_URL="${props.recommend_url}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${props.export_port}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${imagePath} .
# 이미지 푸시
podman push ${imagePath}
echo "Image pushed successfully: ${imagePath}"
"""
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
export namespace=${namespace}
export smarketing_frontend_image_path=${imagePath}
export replicas=${props.replicas}
export export_port=${props.export_port}
export ingress_host=${props.ingress_host}
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}
# API URLs도 export (혹시 사용할 수도 있으니)
export auth_url=${props.auth_url}
export member_url=${props.member_url}
export store_url=${props.store_url}
export menu_url=${props.menu_url}
export sales_url=${props.sales_url}
export content_url=${props.content_url}
export recommend_url=${props.recommend_url}
echo "=== 환경변수 확인 ==="
echo "namespace: \$namespace"
echo "ingress_host: \$ingress_host"
echo "export_port: \$export_port"
echo "========================="
envsubst < deployment/${manifest}.template > deployment/${manifest}
echo "Generated manifest file:"
cat deployment/${manifest}
"""
}
container('azure-cli') {
sh """
kubectl apply -f deployment/${manifest}
echo "Waiting for deployment to be ready..."
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing-frontend --timeout=300s
echo "Deployment completed successfully!"
kubectl -n ${namespace} get pods -l app=smarketing-frontend
kubectl -n ${namespace} get svc smarketing-frontend-service
kubectl -n ${namespace} get ingress
"""
}
}
}
}

View File

@ -15,9 +15,9 @@ export_port=18080
ingress_host=smarketing.20.249.184.228.nip.io
# 리소스 설정 (프론트엔드에 맞게 조정)
resources_requests_cpu=128m # 프론트엔드는 CPU 사용량이 적음
resources_requests_memory=128Mi # 메모리도 적게 사용
resources_limits_cpu=512m # 제한도 낮게 설정
resources_requests_cpu=128m
resources_requests_memory=128Mi
resources_limits_cpu=512m
resources_limits_memory=512Mi
# API URLs (⭐ smarketing-backend ingress를 통해 라우팅)
@ -65,4 +65,4 @@ static_cache_duration=1y
# 압축 설정
gzip_enabled=true
gzip_compression_level=6
gzip_compression_level=6

View File

@ -52,9 +52,9 @@ window.__runtime_config__ = {
'http://localhost:8083/api/content',
RECOMMEND_URL: isProduction() ?
`${baseUrl}/api/recommend` :
`${baseUrl}/api/recommendations` :
'http://localhost:8084/api/recommendations',
// Gateway URL
GATEWAY_URL: isProduction() ? baseUrl : 'http://20.1.2.3',

View File

@ -2,7 +2,8 @@
/**
* AI 마케팅 서비스 - 메인 진입점
* Vue 3 + Vuetify 3 기반 애플리케이션 초기화
*/
*
*/
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

View File

@ -7,14 +7,14 @@ const getApiUrls = () => {
const config = window.__runtime_config__ || {}
return {
GATEWAY_URL: config.GATEWAY_URL || 'http://20.1.2.3',
AUTH_URL: config.AUTH_URL || 'http://localhost:8081/api/auth',
MEMBER_URL: config.MEMBER_URL || 'http://localhost:8081/api/member',
STORE_URL: config.STORE_URL || 'http://localhost:8082/api/store',
CONTENT_URL: config.CONTENT_URL || 'http://localhost:8083/api/content',
MENU_URL: config.MENU_URL || 'http://localhost:8082/api/menu',
SALES_URL: config.SALES_URL || 'http://localhost:8082/api/sales',
RECOMMEND_URL: config.RECOMMEND_URL || 'http://localhost:8084/api/recommendations',
IMAGE_URL: config.IMAGE_URL || 'http://localhost:8082/api/images'
AUTH_URL: config.AUTH_URL || 'http://smarketing.20.249.184.228.nip.io/api/auth',
MEMBER_URL: config.MEMBER_URL || 'http://smarketing.20.249.184.228.nip.io/api/member',
STORE_URL: config.STORE_URL || 'http://smarketing.20.249.184.228.nip.io/api/store',
CONTENT_URL: config.CONTENT_URL || 'http://smarketing.20.249.184.228.nip.io/api/content',
MENU_URL: config.MENU_URL || 'http://smarketing.20.249.184.228.nip.io/api/menu',
SALES_URL: config.SALES_URL || 'http://smarketing.20.249.184.228.nip.io/api/sales',
RECOMMEND_URL: config.RECOMMEND_URL || 'http://smarketing.20.249.184.228.nip.io/api/recommendations',
IMAGE_URL: config.IMAGE_URL || 'http://smarketing.20.249.184.228.nip.io/api/images'
}
}

View File

@ -333,7 +333,7 @@ class ContentService {
// ✅ API 호출
const response = await contentApi.post('/sns/generate', formData, {
timeout: 30000,
timeout: 0,
headers: {
'Content-Type': 'multipart/form-data'
}

View File

@ -1,4 +1,4 @@
//* src/views/ContentCreationView.vue -
//* src/views/ContentCreationView.vue -
<template>
<v-container fluid class="pa-0" style="height: 100vh; overflow: hidden;">
@ -112,8 +112,21 @@
</template>
</v-select>
<!-- 홍보 대상 -->
<!-- 홍보 대상 선택 (SNS) / 음식명 입력 (포스터) -->
<v-text-field
v-if="selectedType === 'poster'"
v-model="formData.menuName"
label="메뉴명"
variant="outlined"
:rules="menuNameRules"
required
density="compact"
class="mb-3"
placeholder="예: 치킨 마요 덮밥, 딸기 라떼"
/>
<v-select
v-else
v-model="formData.targetType"
:items="getTargetTypes(selectedType)"
:label="selectedType === 'poster' ? '포스터 대상' : '홍보 대상'"
@ -122,11 +135,40 @@
required
density="compact"
class="mb-3"
/>
@update:model-value="handleTargetTypeChange"
>
<template v-slot:item="{ props, item }">
<v-list-item
v-bind="props"
:disabled="selectedType === 'poster' && item.value !== 'menu'"
:class="{ 'v-list-item--disabled': selectedType === 'poster' && item.value !== 'menu' }"
@click="handleTargetItemClick(item.value, $event)"
>
<template v-slot:prepend>
<v-icon
:color="(selectedType === 'poster' && item.value !== 'menu') ? 'grey-lighten-2' : 'primary'"
>
mdi-target
</v-icon>
</template>
<v-list-item-title
:class="{ 'text-grey-lighten-1': selectedType === 'poster' && item.value !== 'menu' }"
>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle
v-if="selectedType === 'poster' && item.value !== 'menu'"
class="text-caption text-grey-lighten-1"
>
현재 메뉴만 지원
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<!-- 이벤트명 -->
<!-- 이벤트명 (SNS에서 이벤트 선택 ) -->
<v-text-field
v-if="formData.targetType === 'event'"
v-if="selectedType === 'sns' && formData.targetType === 'event'"
v-model="formData.eventName"
label="이벤트명"
variant="outlined"
@ -142,26 +184,28 @@
<v-text-field
v-model="formData.promotionStartDate"
label="홍보 시작일"
type="datetime-local"
type="date"
variant="outlined"
density="compact"
:rules="promotionStartDateRules"
required
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model="formData.promotionEndDate"
label="홍보 종료일"
type="datetime-local"
type="date"
variant="outlined"
density="compact"
:rules="promotionEndDateRules"
required
/>
</v-col>
</v-row>
<!-- 이벤트 기간 (이벤트인 경우) -->
<v-row v-if="formData.targetType === 'event'">
<!-- 이벤트 기간 (SNS에서 이벤트인 경우) -->
<v-row v-if="selectedType === 'sns' && formData.targetType === 'event'">
<v-col cols="6">
<v-text-field
v-model="formData.startDate"
@ -272,8 +316,8 @@
<v-btn
color="primary"
size="large"
:disabled="!canGenerate || remainingGenerations <= 0 || contentStore.generating"
:loading="contentStore.generating"
:disabled="!canGenerate || remainingGenerations <= 0 || isGenerating"
:loading="isGenerating"
@click="generateContent"
class="px-8"
>
@ -388,7 +432,7 @@
<!-- 콘텐츠 내용 -->
<div class="text-body-2 mb-3" style="line-height: 1.6;">
<!-- 포스터인 경우 이미지로 표시 -->
<!-- 포스터인 경우 이미지로 표시 -->
<div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
<v-img
v-if="currentVersion.posterImage || currentVersion.content"
@ -421,7 +465,7 @@
</div>
</div>
<!-- SNS인 경우 기존 텍스트 표시 -->
<!-- SNS인 경우 기존 텍스트 표시 -->
<div v-else>
<div v-if="isHtmlContent(currentVersion.content)"
class="html-content preview-content">
@ -467,7 +511,7 @@
<v-btn
color="primary"
variant="outlined"
@click="copyToClipboard(currentVersion.content)"
@click="copyFullContent(currentVersion)"
>
<v-icon class="mr-1">mdi-content-copy</v-icon>
복사
@ -504,11 +548,11 @@
<v-divider />
<v-card-text class="pa-4" style="max-height: 500px;">
<!-- 포스터인 경우 이미지 표시, SNS인 경우 텍스트 표시 -->
<!-- 포스터인 경우 이미지 표시, SNS인 경우 텍스트 표시 -->
<div class="mb-4">
<h4 class="text-h6 mb-2">콘텐츠</h4>
<!-- 포스터인 경우 이미지로 표시 -->
<!-- 포스터인 경우 이미지로 표시 -->
<div v-if="currentVersion.contentType === 'poster' || currentVersion.type === 'poster'">
<v-img
v-if="currentVersion.posterImage || currentVersion.content"
@ -547,7 +591,7 @@
</div>
</div>
<!-- SNS인 경우 기존 텍스트 표시 -->
<!-- SNS인 경우 기존 텍스트 표시 -->
<div v-else>
<div v-if="isHtmlContent(currentVersion.content)"
class="pa-3 bg-grey-lighten-5 rounded html-content"
@ -594,13 +638,19 @@
<v-list-item>
<v-list-item-title>홍보 대상</v-list-item-title>
<template v-slot:append>
{{ currentVersion.targetType }}
{{ currentVersion.targetType || '메뉴' }}
</template>
</v-list-item>
<v-list-item v-if="currentVersion.eventName">
<v-list-item v-if="currentVersion.eventName || formData.eventName">
<v-list-item-title>이벤트명</v-list-item-title>
<template v-slot:append>
{{ currentVersion.eventName }}
{{ currentVersion.eventName || formData.eventName }}
</template>
</v-list-item>
<v-list-item v-if="currentVersion.menuName || formData.menuName">
<v-list-item-title>메뉴명</v-list-item-title>
<template v-slot:append>
{{ currentVersion.menuName || formData.menuName }}
</template>
</v-list-item>
<v-list-item>
@ -639,7 +689,7 @@
</v-dialog>
<!-- 로딩 오버레이 -->
<v-overlay v-model="contentStore.generating" contained persistent class="d-flex align-center justify-center">
<v-overlay v-model="isGenerating" contained persistent class="d-flex align-center justify-center">
<div class="text-center">
<v-progress-circular color="primary" indeterminate size="64" class="mb-4" />
<h3 class="text-h6 text-white mb-2">AI가 콘텐츠를 생성 중입니다</h3>
@ -664,23 +714,25 @@ const router = useRouter()
const contentStore = useContentStore()
const appStore = useAppStore()
// - isGenerating
//
const selectedType = ref('sns')
const uploadedFiles = ref([])
const previewImages = ref([])
const isPublishing = ref(false)
const isGenerating = ref(false) //
const isGenerating = ref(false)
const publishingIndex = ref(-1)
const showDetailDialog = ref(false)
const selectedVersion = ref(0)
const generatedVersions = ref([])
const remainingGenerations = ref(3)
const formValid = ref(false)
//
const formData = ref({
title: '',
platform: '',
targetType: '',
menuName: '',
eventName: '',
startDate: '',
endDate: '',
@ -713,7 +765,7 @@ const contentTypes = [
{
value: 'sns',
label: 'SNS 게시물',
description: '인스타그램, 페이스북 등',
description: '인스타그램, 네이버블로그 등',
icon: 'mdi-instagram',
color: 'pink'
},
@ -728,15 +780,13 @@ const contentTypes = [
const platformOptions = [
{ title: '인스타그램', value: 'instagram' },
{ title: '네이버 블로그', value: 'naver_blog' },
{ title: '페이스북', value: 'facebook' },
{ title: '카카오스토리', value: 'kakao_story' }
{ title: '네이버 블로그', value: 'naver_blog' }
]
const targetTypes = [
{ title: '메뉴', value: 'menu' },
{ title: '매장', value: 'store' },
{ title: '이벤트', value: 'event' },
{ title: '이벤트', value: 'event' }
]
//
@ -754,13 +804,34 @@ const getTargetTypes = (type) => {
if (type === 'poster') {
return [
{ title: '메뉴', value: 'menu' },
{ title: '이벤트', value: 'event' },
{ title: '매장', value: 'store' },
{ title: '이벤트', value: 'event' },
{ title: '서비스', value: 'service' },
{ title: '할인혜택', value: 'discount' }
]
} else {
return targetTypes
}
// SNS
return [
{ title: '메뉴', value: 'menu' },
{ title: '매장', value: 'store' },
{ title: '이벤트', value: 'event' }
]
}
// ( )
const handleTargetItemClick = (value, event) => {
if (selectedType.value === 'poster' && value !== 'menu') {
event.preventDefault()
event.stopPropagation()
appStore.showSnackbar('현재 포스터는 메뉴 대상만 지원됩니다.', 'warning')
return false
}
}
const handleTargetTypeChange = (value) => {
if (selectedType.value === 'poster' && value !== 'menu') {
formData.value.targetType = 'menu'
appStore.showSnackbar('현재 포스터는 메뉴 대상만 지원됩니다.', 'warning')
}
}
@ -778,6 +849,11 @@ const targetRules = [
v => !!v || '홍보 대상을 선택해주세요'
]
const menuNameRules = [
v => !!v || '메뉴명은 필수입니다',
v => (v && v.length <= 50) || '메뉴명은 50자 이하로 입력해주세요'
]
const eventNameRules = [
v => !formData.value.targetType || formData.value.targetType !== 'event' || !!v || '이벤트명은 필수입니다'
]
@ -807,51 +883,18 @@ const promotionEndDateRules = [
}
]
// Computed
const formValid = computed(() => {
//
if (!formData.value.title || !formData.value.targetType) {
return false
}
// SNS
if (selectedType.value === 'sns' && !formData.value.platform) {
return false
}
//
if (formData.value.targetType === 'event') {
if (!formData.value.eventName || !formData.value.startDate || !formData.value.endDate) {
return false
}
}
//
if (selectedType.value === 'poster') {
if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) {
return false
}
//
if (!previewImages.value || previewImages.value.length === 0) {
return false
}
}
return true
})
// Computed
const canGenerate = computed(() => {
try {
//
if (!formValid.value) return false
if (!selectedType.value) return false
if (!formData.value.title) return false
// SNS
if (selectedType.value === 'sns' && !formData.value.platform) return false
//
// ,
if (selectedType.value === 'poster') {
if (!formData.value.menuName) return false
if (!previewImages.value || previewImages.value.length === 0) return false
if (!formData.value.promotionStartDate || !formData.value.promotionEndDate) return false
}
@ -876,19 +919,60 @@ const currentVersion = computed(() => {
//
const selectContentType = (type) => {
selectedType.value = type
console.log(`${type} 타입 선택됨`)
console.log(`${type} 타입 선택됨 - 폼 데이터 초기화`)
// ( )
formData.value = {
title: '',
platform: '',
targetType: type === 'poster' ? 'menu' : '', //
menuName: '',
eventName: '',
startDate: '',
endDate: '',
content: '',
hashtags: [],
category: '기타',
targetAge: '20대',
promotionStartDate: '',
promotionEndDate: '',
requirements: '',
toneAndManner: '친근함',
emotionIntensity: '보통',
imageStyle: '모던',
promotionType: '할인 정보',
photoStyle: '밝고 화사한'
}
//
uploadedFiles.value = []
previewImages.value = []
// AI
aiOptions.value = {
toneAndManner: 'friendly',
promotion: 'general',
emotionIntensity: 'normal',
photoStyle: '밝고 화사한',
imageStyle: '모던',
targetAge: '20대',
}
console.log('✅ 폼 데이터 초기화 완료:', {
type: type,
targetType: formData.value.targetType,
preservedVersions: generatedVersions.value.length
})
}
const handleFileUpload = (files) => {
console.log('📁 파일 업로드 이벤트:', files)
//
if (!files || (Array.isArray(files) && files.length === 0)) {
console.log('📁 파일이 없음 - 기존 이미지 유지')
return
}
//
let fileArray = []
if (files instanceof FileList) {
fileArray = Array.from(files)
@ -901,10 +985,8 @@ const handleFileUpload = (files) => {
console.log('📁 처리할 파일 개수:', fileArray.length)
// ( )
previewImages.value = []
//
fileArray.forEach((file, index) => {
if (file && file.type && file.type.startsWith('image/')) {
const reader = new FileReader()
@ -912,11 +994,9 @@ const handleFileUpload = (files) => {
reader.onload = (e) => {
console.log(`📁 파일 ${index + 1} 읽기 완료: ${file.name}`)
//
const existingIndex = previewImages.value.findIndex(img => img.name === file.name && img.size === file.size)
if (existingIndex === -1) {
//
previewImages.value.push({
file: file,
url: e.target.result,
@ -944,7 +1024,6 @@ const removeImage = (index) => {
console.log('🗑️ 이미지 삭제:', index)
previewImages.value.splice(index, 1)
//
if (uploadedFiles.value && uploadedFiles.value.length > index) {
const newFiles = Array.from(uploadedFiles.value)
newFiles.splice(index, 1)
@ -952,9 +1031,10 @@ const removeImage = (index) => {
}
}
// 1. generateContent -
const generateContent = async () => {
if (!formValid.value) {
appStore.showSnackbar('모든 필수 항목을 입력해주세요.', 'warning')
if (!formData.value.title?.trim()) {
appStore.showSnackbar('목을 입력해주세요.', 'warning')
return
}
@ -963,6 +1043,13 @@ const generateContent = async () => {
return
}
//
if (selectedType.value === 'poster' && formData.value.targetType !== 'menu') {
appStore.showSnackbar('포스터는 메뉴 대상만 생성 가능합니다.', 'warning')
formData.value.targetType = 'menu'
return
}
isGenerating.value = true
try {
@ -970,37 +1057,73 @@ const generateContent = async () => {
console.log('📋 [UI] 폼 데이터:', formData.value)
console.log('📁 [UI] 이미지 데이터:', previewImages.value)
// ID
let storeId = 1 //
// ID - API
let storeId = null
try {
// localStorage
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
const storeApiUrl = (window.__runtime_config__ && window.__runtime_config__.STORE_URL)
? window.__runtime_config__.STORE_URL
: 'http://localhost:8082/api/store'
if (storeInfo.storeId) {
storeId = storeInfo.storeId
} else if (userInfo.storeId) {
storeId = userInfo.storeId
const token = localStorage.getItem('accessToken') || localStorage.getItem('auth_token') || localStorage.getItem('token')
if (!token) {
throw new Error('인증 토큰이 없습니다.')
}
const storeResponse = await fetch(`${storeApiUrl}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
if (storeResponse.ok) {
const storeData = await storeResponse.json()
storeId = storeData.data?.storeId
console.log('✅ 매장 정보 조회 성공, storeId:', storeId)
} else {
console.warn('⚠️ localStorage에서 매장 ID를 찾을 수 없음, 기본값 사용:', storeId)
throw new Error(`매장 정보 조회 실패: ${storeResponse.status}`)
}
} catch (error) {
console.warn('⚠️ 매장 정보 파싱 실패, 기본값 사용:', storeId)
console.error('❌ 매장 정보 조회 실패:', error)
// fallback: localStorage
try {
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
if (storeInfo.storeId) {
storeId = storeInfo.storeId
console.log('⚠️ fallback - localStorage에서 매장 ID 사용:', storeId)
} else if (userInfo.storeId) {
storeId = userInfo.storeId
console.log('⚠️ fallback - userInfo에서 매장 ID 사용:', storeId)
} else {
throw new Error('매장 정보를 찾을 수 없습니다. 매장 관리 페이지에서 매장을 등록해주세요.')
}
} catch (fallbackError) {
console.error('❌ fallback 실패:', fallbackError)
throw new Error('매장 정보를 찾을 수 없습니다. 매장 관리 페이지에서 매장을 등록해주세요.')
}
}
if (!storeId) {
throw new Error('매장 ID를 가져올 수 없습니다. 매장 관리 페이지에서 매장을 등록해주세요.')
}
console.log('🏪 [UI] 사용할 매장 ID:', storeId)
// Base64 URL
// Base64 URL
const imageUrls = previewImages.value?.map(img => img.url).filter(url => url) || []
console.log('📁 [UI] 추출된 이미지 URL들:', imageUrls)
//
//
if (selectedType.value === 'poster' && imageUrls.length === 0) {
throw new Error('포스터 생성을 위해 최소 1개의 이미지가 필요합니다.')
}
//
//
const contentData = {
title: formData.value.title,
platform: formData.value.platform || (selectedType.value === 'poster' ? 'POSTER' : 'INSTAGRAM'),
@ -1016,22 +1139,27 @@ const generateContent = async () => {
endDate: formData.value.endDate,
toneAndManner: formData.value.toneAndManner || '친근함',
emotionIntensity: formData.value.emotionIntensity || '보통',
images: imageUrls, // Base64 URL
storeId: storeId // ID
images: imageUrls,
storeId: storeId
}
//
//
if (selectedType.value === 'poster') {
contentData.promotionStartDate = formData.value.promotionStartDate
contentData.promotionEndDate = formData.value.promotionEndDate
contentData.imageStyle = formData.value.imageStyle || '모던'
contentData.promotionType = formData.value.promotionType
contentData.photoStyle = formData.value.photoStyle || '밝고 화사한'
contentData.menuName = formData.value.menuName.trim()
contentData.targetAudience = aiOptions.value.targetAge || '20대'
contentData.category = '메뉴소개'
if (formData.value.promotionStartDate) {
contentData.promotionStartDate = new Date(formData.value.promotionStartDate).toISOString()
}
if (formData.value.promotionEndDate) {
contentData.promotionEndDate = new Date(formData.value.promotionEndDate).toISOString()
}
}
console.log('📤 [UI] 생성 요청 데이터:', contentData)
// contentData
// contentData
if (!contentData || typeof contentData !== 'object') {
throw new Error('콘텐츠 데이터 구성에 실패했습니다.')
}
@ -1041,7 +1169,7 @@ const generateContent = async () => {
contentData.images = []
}
// Store
// Store
console.log('🚀 [UI] contentStore.generateContent 호출')
const generated = await contentStore.generateContent(contentData)
@ -1049,18 +1177,16 @@ const generateContent = async () => {
throw new Error(generated?.message || '콘텐츠 생성에 실패했습니다.')
}
//
//
let finalContent = ''
let posterImageUrl = ''
if (selectedType.value === 'poster') {
// generated.data URL
posterImageUrl = generated.data?.posterImage || generated.data?.content || generated.content || ''
finalContent = posterImageUrl // content URL
finalContent = posterImageUrl
console.log('🖼️ [UI] 포스터 이미지 URL:', posterImageUrl)
} else {
// SNS
finalContent = generated.content || generated.data?.content || ''
// SNS
@ -1079,18 +1205,19 @@ const generateContent = async () => {
}
}
//
//
const newContent = {
id: Date.now() + Math.random(),
...contentData,
content: finalContent,
posterImage: posterImageUrl, // URL
posterImage: posterImageUrl,
hashtags: generated.hashtags || generated.data?.hashtags || [],
createdAt: new Date(),
status: 'draft',
uploadedImages: previewImages.value || [], //
images: imageUrls, // Base64 URL
platform: contentData.platform || 'POSTER'
uploadedImages: previewImages.value || [],
images: imageUrls,
platform: contentData.platform || 'POSTER',
menuName: formData.value.menuName || ''
}
generatedVersions.value.push(newContent)
@ -1124,6 +1251,7 @@ const selectVersion = (index) => {
selectedVersion.value = index
}
// 2. saveVersion -
const saveVersion = async (index) => {
isPublishing.value = true
publishingIndex.value = index
@ -1133,10 +1261,38 @@ const saveVersion = async (index) => {
console.log('💾 [UI] 저장할 버전 데이터:', version)
// ID
let storeId = 1 //
// ID - API
let storeId = null
try {
const storeApiUrl = (window.__runtime_config__ && window.__runtime_config__.STORE_URL)
? window.__runtime_config__.STORE_URL
: 'http://localhost:8082/api/store'
const token = localStorage.getItem('accessToken') || localStorage.getItem('auth_token') || localStorage.getItem('token')
if (!token) {
throw new Error('인증 토큰이 없습니다.')
}
const storeResponse = await fetch(`${storeApiUrl}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
})
if (storeResponse.ok) {
const storeData = await storeResponse.json()
storeId = storeData.data?.storeId
console.log('✅ [저장] 매장 정보 조회 성공, storeId:', storeId)
} else {
throw new Error(`매장 정보 조회 실패: ${storeResponse.status}`)
}
} catch (error) {
console.error('❌ [저장] 매장 정보 조회 실패:', error)
// fallback
const storeInfo = JSON.parse(localStorage.getItem('storeInfo') || '{}')
const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}')
@ -1145,54 +1301,48 @@ const saveVersion = async (index) => {
} else if (userInfo.storeId) {
storeId = userInfo.storeId
} else {
console.warn('⚠️ localStorage에서 매장 ID를 찾을 수 없음, 기본값 사용:', storeId)
throw new Error('매장 정보를 찾을 수 없습니다.')
}
} catch (error) {
console.warn('⚠️ 매장 정보 파싱 실패, 기본값 사용:', storeId)
}
if (!storeId) {
throw new Error('매장 ID를 가져올 수 없습니다.')
}
console.log('🏪 [UI] 사용할 매장 ID:', storeId)
//
//
let imageUrls = []
// URL
if (selectedType.value === 'poster') {
// 1. URL
if (version.posterImage) {
imageUrls.push(version.posterImage)
console.log('💾 [UI] 생성된 포스터 이미지:', version.posterImage)
}
// 2. previewImages URL
if (previewImages.value && previewImages.value.length > 0) {
const originalImages = previewImages.value.map(img => img.url).filter(url => url)
imageUrls = [...imageUrls, ...originalImages]
console.log('💾 [UI] 원본 이미지들:', originalImages)
}
// 3. version
if (version.uploadedImages && version.uploadedImages.length > 0) {
const versionImages = version.uploadedImages.map(img => img.url).filter(url => url)
imageUrls = [...imageUrls, ...versionImages]
}
// 4. version.images
if (version.images && Array.isArray(version.images) && version.images.length > 0) {
imageUrls = [...imageUrls, ...version.images]
}
//
imageUrls = [...new Set(imageUrls)]
console.log('💾 [UI] 포스터 최종 이미지 URL들:', imageUrls)
//
if (!imageUrls || imageUrls.length === 0) {
throw new Error('포스터 저장을 위해 최소 1개의 이미지가 필요합니다.')
}
} else {
// SNS
if (previewImages.value && previewImages.value.length > 0) {
imageUrls = previewImages.value.map(img => img.url).filter(url => url)
}
@ -1203,67 +1353,44 @@ const saveVersion = async (index) => {
console.log('💾 [UI] 최종 이미지 URL들:', imageUrls)
// -
// -
let saveData
if (selectedType.value === 'poster') {
// (PosterContentSaveRequest )
saveData = {
// ID
storeId: storeId,
// - content URL
title: version.title,
content: version.posterImage || version.content, // URL content
images: imageUrls, //
//
content: version.posterImage || version.content,
images: imageUrls,
category: getCategory(version.targetType || formData.value.targetType),
requirement: formData.value.requirements || `${version.title}에 대한 포스터를 만들어주세요`,
//
eventName: version.eventName || formData.value.eventName,
startDate: formData.value.startDate,
endDate: formData.value.endDate,
//
photoStyle: formData.value.photoStyle || '밝고 화사한'
}
} else {
// SNS (SnsContentSaveRequest )
saveData = {
// ID
storeId: storeId,
//
contentType: 'SNS',
platform: version.platform || formData.value.platform || 'INSTAGRAM',
//
title: version.title,
content: version.content,
hashtags: version.hashtags || [],
images: imageUrls,
//
category: getCategory(version.targetType || formData.value.targetType),
requirement: formData.value.requirements || `${version.title}에 대한 SNS 게시물을 만들어주세요`,
toneAndManner: formData.value.toneAndManner || '친근함',
emotionIntensity: formData.value.emotionIntensity || '보통',
//
eventName: version.eventName || formData.value.eventName,
startDate: formData.value.startDate,
endDate: formData.value.endDate,
//
status: 'PUBLISHED'
}
}
console.log('💾 [UI] 최종 저장 데이터:', saveData)
//
await contentStore.saveContent(saveData)
version.status = 'published'
@ -1297,18 +1424,32 @@ const copyToClipboard = async (content) => {
}
}
// - SNS
const copyFullContent = async (version) => {
try {
let fullContent = ''
if (isHtmlContent(version.content)) {
fullContent += extractTextFromHtml(version.content)
//
if (selectedType.value === 'poster' || version.contentType === 'poster' || version.type === 'poster') {
fullContent = version.title || '포스터'
if (formData.value.requirements) {
fullContent += '\n\n' + formData.value.requirements
}
if (version.posterImage || version.content) {
fullContent += '\n\n포스터 이미지: ' + (version.posterImage || version.content)
}
} else {
fullContent += version.content
}
if (version.hashtags && version.hashtags.length > 0) {
fullContent += '\n\n' + version.hashtags.join(' ')
// SNS HTML
if (isHtmlContent(version.content)) {
fullContent += extractTextFromHtml(version.content)
} else {
fullContent += version.content || ''
}
//
if (version.hashtags && version.hashtags.length > 0) {
fullContent += '\n\n' + version.hashtags.join(' ')
}
}
await navigator.clipboard.writeText(fullContent)
@ -1352,14 +1493,8 @@ const getPlatformColor = (platform) => {
const getPlatformLabel = (platform) => {
const labels = {
'instagram': '인스타그램',
'naver_blog': '네이버 블로그',
'facebook': '페이스북',
'kakao_story': '카카오스토리',
'INSTAGRAM': '인스타그램',
'NAVER_BLOG': '네이버 블로그',
'FACEBOOK': '페이스북',
'KAKAO_STORY': '카카오스토리',
'POSTER': '포스터'
}
return labels[platform] || platform
@ -1407,11 +1542,28 @@ const isHtmlContent = (content) => {
return /<[^>]+>/.test(content)
}
// HTML
const extractTextFromHtml = (html) => {
if (!html) return ''
const tempDiv = document.createElement('div')
tempDiv.innerHTML = html
return tempDiv.textContent || tempDiv.innerText || ''
try {
// HTML
const textContent = html
.replace(/<br\s*\/?>/gi, '\n') // <br>
.replace(/<\/p>/gi, '\n\n') // </p>
.replace(/<[^>]*>/g, '') // HTML
.replace(/&nbsp;/g, ' ') // &nbsp;
.replace(/&amp;/g, '&') // &amp; &
.replace(/&lt;/g, '<') // &lt; <
.replace(/&gt;/g, '>') // &gt; >
.replace(/&quot;/g, '"') // &quot; "
.trim()
return textContent
} catch (error) {
console.error('HTML 텍스트 추출 실패:', error)
return html
}
}
const truncateHtmlContent = (html, maxLength) => {
@ -1445,7 +1597,43 @@ const handleImageError = (event) => {
//
onMounted(() => {
console.log('📱 콘텐츠 생성 페이지 로드됨')
//
console.log('🔍 초기 상태 확인:')
console.log('- selectedType:', selectedType.value)
console.log('- formData:', formData.value)
console.log('- previewImages:', previewImages.value)
console.log('- canGenerate 존재:', typeof canGenerate)
// 5
setTimeout(() => {
console.log('🔍 5초 후 상태:')
console.log('- formData.title:', formData.value.title)
console.log('- formData.menuName:', formData.value.menuName)
console.log('- canGenerate:', canGenerate?.value)
}, 5000)
})
// formData
watch(() => formData.value, (newVal) => {
console.log('📝 formData 실시간 변경:', {
title: newVal.title,
menuName: newVal.menuName,
targetType: newVal.targetType,
promotionStartDate: newVal.promotionStartDate,
promotionEndDate: newVal.promotionEndDate
})
}, { deep: true })
// canGenerate
watch(canGenerate, (newVal) => {
console.log('🎯 canGenerate 변경:', newVal)
})
// previewImages
watch(() => previewImages.value, (newVal) => {
console.log('📁 previewImages 변경:', newVal.length, '개')
}, { deep: true })
</script>
<style scoped>
@ -1527,5 +1715,4 @@ onMounted(() => {
background: linear-gradient(transparent, white);
pointer-events: none;
}
</style>
</style>

View File

@ -232,14 +232,7 @@
<p class="text-caption text-grey-darken-1 mb-0">맞춤형 마케팅 제안</p>
</div>
</div>
<v-btn
icon="mdi-refresh"
size="small"
variant="text"
color="primary"
:loading="aiLoading"
@click="refreshAiRecommendation"
/>
</div>
</v-card-title>
@ -252,7 +245,7 @@
<div v-else-if="aiRecommendation" class="ai-recommendation-content">
<!-- 추천 제목 -->
<div class="recommendation-header mb-4">
</div>
<!-- 스크롤 가능한 콘텐츠 영역 -->
@ -791,115 +784,146 @@ const updateDashboardMetrics = (salesData) => {
}
/**
* 차트 데이터 업데이트 (수정 - 핵심 차트 연동 로직)
* 차트 데이터 업데이트 (하드코딩된 7 데이터 사용)
*/
const updateChartData = (salesData) => {
try {
console.log('📊 [CHART] 차트 데이터 업데이트 시작:', salesData)
// yearSales
if (salesData.yearSales && salesData.yearSales.length > 0) {
// Sales
const salesDataPoints = salesData.yearSales.slice(-7).map((sale, index) => {
const date = new Date(sale.salesDate)
const label = `${date.getMonth() + 1}/${date.getDate()}`
const amount = Number(sale.salesAmount) / 10000 //
const originalAmount = Number(sale.salesAmount) //
return {
label: index === salesData.yearSales.length - 1 ? '오늘' : label,
sales: Math.round(amount),
target: Math.round(amount * 1.1), // 110%
date: sale.salesDate,
originalSales: originalAmount, //
originalTarget: Math.round(originalAmount * 1.1) //
}
})
// 7 ( DB )
const hardcodedSalesData = [
{ salesDate: '2025-06-14', salesAmount: 230000 },
{ salesDate: '2025-06-15', salesAmount: 640000 },
{ salesDate: '2025-06-16', salesAmount: 140000 },
{ salesDate: '2025-06-17', salesAmount: 800000 },
{ salesDate: '2025-06-18', salesAmount: 900000 },
{ salesDate: '2025-06-19', salesAmount: 500000 },
{ salesDate: '2025-06-20', salesAmount: 1600000 }
]
console.log('📊 [CHART] 하드코딩된 7일 데이터 사용:', hardcodedSalesData)
// Sales
const salesDataPoints = hardcodedSalesData.map((sale, index) => {
const date = new Date(sale.salesDate)
const label = `${date.getMonth() + 1}/${date.getDate()}`
const amount = Number(sale.salesAmount) / 10000 //
const originalAmount = Number(sale.salesAmount) //
console.log('📊 [CHART] 변환된 7일 데이터:', salesDataPoints)
// ''
const displayLabel = index === hardcodedSalesData.length - 1 ? '오늘' : label
// 7
chartData.value['7d'] = salesDataPoints
originalChartData.value['7d'] = salesDataPoints //
// 30/90 ( )
if (salesData.yearSales.length >= 30) {
// 30
const weeklyData = []
for (let i = 0; i < 5; i++) {
const weekStart = Math.max(0, salesData.yearSales.length - 35 + (i * 7))
const weekEnd = Math.min(salesData.yearSales.length, weekStart + 7)
const weekSales = salesData.yearSales.slice(weekStart, weekEnd)
if (weekSales.length > 0) {
const totalAmount = weekSales.reduce((sum, sale) => sum + Number(sale.salesAmount), 0)
const avgAmount = totalAmount / weekSales.length / 10000 //
const originalAvgAmount = totalAmount / weekSales.length //
weeklyData.push({
label: i === 4 ? '이번주' : `${i + 1}주차`,
sales: Math.round(avgAmount),
target: Math.round(avgAmount * 1.1),
date: `Week ${i + 1}`,
originalSales: Math.round(originalAvgAmount), //
originalTarget: Math.round(originalAvgAmount * 1.1) //
})
}
}
if (weeklyData.length > 0) {
chartData.value['30d'] = weeklyData
originalChartData.value['30d'] = weeklyData //
console.log('📊 [CHART] 30일(주간) 데이터 생성:', weeklyData)
}
return {
label: displayLabel,
sales: Math.round(amount),
target: Math.round(amount * 1.1), // 110%
date: sale.salesDate,
originalSales: originalAmount,
originalTarget: Math.round(originalAmount * 1.1)
}
if (salesData.yearSales.length >= 90) {
// 90
const monthlyData = []
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']
// 4
for (let i = 0; i < 4; i++) {
const monthStart = Math.max(0, salesData.yearSales.length - 120 + (i * 30))
const monthEnd = Math.min(salesData.yearSales.length, monthStart + 30)
const monthSales = salesData.yearSales.slice(monthStart, monthEnd)
if (monthSales.length > 0) {
const totalAmount = monthSales.reduce((sum, sale) => sum + Number(sale.salesAmount), 0)
const avgAmount = totalAmount / monthSales.length / 10000 //
const originalAvgAmount = totalAmount / monthSales.length //
const currentMonth = new Date().getMonth()
const monthIndex = (currentMonth - 3 + i + 12) % 12
monthlyData.push({
label: i === 3 ? '이번달' : monthNames[monthIndex],
sales: Math.round(avgAmount * 10), // 10
target: Math.round(avgAmount * 11),
date: `Month ${i + 1}`,
originalSales: Math.round(originalAvgAmount * 10), //
originalTarget: Math.round(originalAvgAmount * 11) //
})
}
}
if (monthlyData.length > 0) {
chartData.value['90d'] = monthlyData
originalChartData.value['90d'] = monthlyData //
console.log('📊 [CHART] 90일(월간) 데이터 생성:', monthlyData)
}
})
console.log('📊 [CHART] 변환된 7일 차트 데이터:', salesDataPoints)
// 7
chartData.value['7d'] = salesDataPoints
originalChartData.value['7d'] = salesDataPoints
// 30 ( )
const weeklyData = [
{
label: '1주차',
sales: Math.round((230000 + 640000) / 2 / 10000), // 23 + 64 / 2 = 43.5
target: Math.round((230000 + 640000) / 2 / 10000 * 1.1),
date: 'Week 1',
originalSales: Math.round((230000 + 640000) / 2),
originalTarget: Math.round((230000 + 640000) / 2 * 1.1)
},
{
label: '2주차',
sales: Math.round((140000 + 800000) / 2 / 10000), // 14 + 80 / 2 = 47
target: Math.round((140000 + 800000) / 2 / 10000 * 1.1),
date: 'Week 2',
originalSales: Math.round((140000 + 800000) / 2),
originalTarget: Math.round((140000 + 800000) / 2 * 1.1)
},
{
label: '3주차',
sales: Math.round((900000 + 500000) / 2 / 10000), // 90 + 50 / 2 = 70
target: Math.round((900000 + 500000) / 2 / 10000 * 1.1),
date: 'Week 3',
originalSales: Math.round((900000 + 500000) / 2),
originalTarget: Math.round((900000 + 500000) / 2 * 1.1)
},
{
label: '4주차',
sales: Math.round(1600000 / 10000), // 160
target: Math.round(1600000 / 10000 * 1.1),
date: 'Week 4',
originalSales: 1600000,
originalTarget: Math.round(1600000 * 1.1)
},
{
label: '이번주',
sales: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7 / 10000), //
target: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7 / 10000 * 1.1),
date: 'Week 5',
originalSales: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7),
originalTarget: Math.round((230000 + 640000 + 140000 + 800000 + 900000 + 500000 + 1600000) / 7 * 1.1)
}
//
nextTick(() => {
drawChart()
})
console.log('📊 [CHART] 차트 데이터 업데이트 완료')
} else {
console.warn('⚠️ [CHART] yearSales 데이터가 없음, 기본 차트 유지')
}
]
chartData.value['30d'] = weeklyData
originalChartData.value['30d'] = weeklyData
console.log('📊 [CHART] 30일(주간) 데이터 생성:', weeklyData)
// 90 ( )
const monthlyData = [
{
label: '3월',
sales: Math.round((230000 + 640000) / 2 / 1000), // ( )
target: Math.round((230000 + 640000) / 2 / 1000 * 1.1),
date: 'Month 1',
originalSales: Math.round((230000 + 640000) / 2 * 10),
originalTarget: Math.round((230000 + 640000) / 2 * 11)
},
{
label: '4월',
sales: Math.round((140000 + 800000) / 2 / 1000),
target: Math.round((140000 + 800000) / 2 / 1000 * 1.1),
date: 'Month 2',
originalSales: Math.round((140000 + 800000) / 2 * 10),
originalTarget: Math.round((140000 + 800000) / 2 * 11)
},
{
label: '5월',
sales: Math.round((900000 + 500000) / 2 / 1000),
target: Math.round((900000 + 500000) / 2 / 1000 * 1.1),
date: 'Month 3',
originalSales: Math.round((900000 + 500000) / 2 * 10),
originalTarget: Math.round((900000 + 500000) / 2 * 11)
},
{
label: '이번달',
sales: Math.round(1600000 / 1000),
target: Math.round(1600000 / 1000 * 1.1),
date: 'Month 4',
originalSales: Math.round(1600000 * 10),
originalTarget: Math.round(1600000 * 11)
}
]
chartData.value['90d'] = monthlyData
originalChartData.value['90d'] = monthlyData
console.log('📊 [CHART] 90일(월간) 데이터 생성:', monthlyData)
//
nextTick(() => {
drawChart()
})
console.log('📊 [CHART] 하드코딩된 차트 데이터 업데이트 완료')
} catch (error) {
console.error('❌ [CHART] 차트 데이터 업데이트 실패:', error)
//

View File

@ -29,7 +29,7 @@
@keyup.enter="handleLogin"
/>
<!-- 비밀번호 입력 -->
<!-- 비밀번호 입력 -->
<v-text-field
v-model="credentials.password"
label="비밀번호"
@ -45,7 +45,6 @@
@keyup.enter="handleLogin"
/>
<!-- 로그인 옵션 -->
<div class="d-flex justify-space-between align-center mb-6">
<v-checkbox
@ -109,10 +108,10 @@
<h3 class="text-subtitle-2 font-weight-bold mb-2">데모 계정 정보</h3>
<div class="demo-info">
<div class="text-body-2 mb-1">
<span class="font-weight-medium">아이디:</span> user01
<span class="font-weight-medium">아이디:</span> test
</div>
<div class="text-body-2 mb-2">
<span class="font-weight-medium">비밀번호:</span> passw0rd
<span class="font-weight-medium">비밀번호:</span> test1234!
</div>
<v-btn size="small" color="info" variant="outlined" @click="fillDemoCredentials">
데모 계정 자동 입력
@ -328,8 +327,8 @@ const emailChecked = ref(false)
//
const credentials = ref({
username: 'user01',
password: 'passw0rd',
username: 'test',
password: 'test1234!',
})
//
@ -410,8 +409,8 @@ const businessNumberRules = [
//
const fillDemoCredentials = () => {
credentials.value.username = 'user01'
credentials.value.password = 'passw0rd'
credentials.value.username = 'test'
credentials.value.password = 'test1234!'
loginError.value = ''
}

View File

@ -78,17 +78,42 @@
<v-card-text class="pa-6">
<v-row>
<!-- 매장 이미지 -->
<!-- 매장 이미지 섹션 -->
<v-col cols="12" md="4" class="text-center">
<v-avatar size="120" class="mb-3">
<v-img
:src="storeInfo.imageUrl || '/images/store-placeholder.png'"
alt="매장 이미지"
/>
</v-avatar>
<div class="store-image-container mb-3">
<!-- 매장 사진이 있을 -->
<v-avatar
v-if="storeInfo.storeImage || storeInfo.imageUrl"
size="120"
class="store-avatar"
>
<v-img
:src="storeInfo.storeImage || storeInfo.imageUrl"
alt="매장 이미지"
/>
</v-avatar>
<!-- 매장 사진이 없을 - 업종별 이모지 표시 -->
<div
v-else
class="store-emoji-container d-flex align-center justify-center"
:style="{
width: '120px',
height: '120px',
borderRadius: '50%',
backgroundColor: getStoreColor(storeInfo.businessType),
fontSize: '48px'
}"
>
{{ getStoreEmoji(storeInfo.businessType) }}
</div>
</div>
<h3 class="text-h6 font-weight-bold">{{ storeInfo.storeName }}</h3>
<p class="text-grey">{{ storeInfo.businessType }}</p>
</v-col>
<!-- 기본 정보 -->
<v-col cols="12" md="8">
<v-row>
@ -1666,6 +1691,110 @@ onMounted(async () => {
showSnackbar('매장 정보를 불러오는 중 오류가 발생했습니다', 'error')
}
})
//
const getStoreEmoji = (businessType) => {
const emojiMap = {
'카페': '☕',
'레스토랑': '🍽️',
'한식': '🍲',
'중식': '🥢',
'일식': '🍣',
'양식': '🍝',
'치킨': '🍗',
'피자': '🍕',
'햄버거': '🍔',
'분식': '🍜',
'베이커리': '🥐',
'디저트': '🧁',
'아이스크림': '🍦',
'술집': '🍺',
'바': '🍸',
'펜션': '🏠',
'호텔': '🏨',
'게스트하우스': '🏡',
'마트': '🛒',
'편의점': '🏪',
'미용실': '💇',
'네일샵': '💅',
'세탁소': '👔',
'약국': '💊',
'병원': '🏥',
'헬스장': '💪',
'학원': '📚',
'키즈카페': '🧸',
'반려동물': '🐾',
'꽃집': '🌸',
'문구점': '✏️',
'서점': '📖',
'화장품': '💄',
'옷가게': '👗',
'신발가게': '👟',
'가구점': '🪑',
'전자제품': '📱',
'자동차': '🚗',
'주유소': '⛽',
'세차장': '🚿',
'부동산': '🏢',
'은행': '🏦',
'우체국': '📮',
'기타': '🏪'
}
return emojiMap[businessType] || '🏪'
}
//
const getStoreColor = (businessType) => {
const colorMap = {
'카페': '#8D6E63',
'레스토랑': '#FF7043',
'한식': '#D32F2F',
'중식': '#F57C00',
'일식': '#388E3C',
'양식': '#303F9F',
'치킨': '#FBC02D',
'피자': '#E64A19',
'햄버거': '#795548',
'분식': '#FF5722',
'베이커리': '#F57C00',
'디저트': '#E91E63',
'아이스크림': '#00BCD4',
'술집': '#FF9800',
'바': '#9C27B0',
'펜션': '#4CAF50',
'호텔': '#2196F3',
'게스트하우스': '#009688',
'마트': '#607D8B',
'편의점': '#3F51B5',
'미용실': '#E91E63',
'네일샵': '#9C27B0',
'세탁소': '#00BCD4',
'약국': '#4CAF50',
'병원': '#2196F3',
'헬스장': '#FF5722',
'학원': '#673AB7',
'키즈카페': '#FFEB3B',
'반려동물': '#795548',
'꽃집': '#E91E63',
'문구점': '#FF9800',
'서점': '#795548',
'화장품': '#E91E63',
'옷가게': '#9C27B0',
'신발가게': '#607D8B',
'가구점': '#8BC34A',
'전자제품': '#607D8B',
'자동차': '#424242',
'주유소': '#FF5722',
'세차장': '#00BCD4',
'부동산': '#795548',
'은행': '#2196F3',
'우체국': '#FF5722',
'기타': '#9E9E9E'
}
return colorMap[businessType] || '#9E9E9E'
}
</script>
<style scoped>
@ -2208,4 +2337,23 @@ onMounted(async () => {
.store-dialog-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.store-image-container {
position: relative;
display: inline-block;
}
.store-avatar {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.store-emoji-container {
margin: 0 auto;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.store-emoji-container:hover {
transform: scale(1.05);
}
</style>