diff --git a/deployment/Jenkinsfile b/deployment/Jenkinsfile index 8118388..505f1e3 100644 --- a/deployment/Jenkinsfile +++ b/deployment/Jenkinsfile @@ -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' } } -} \ No newline at end of file +} diff --git a/deployment/Jenkinsfile_ArgoCD b/deployment/Jenkinsfile_ArgoCD deleted file mode 100644 index 0c6aec3..0000000 --- a/deployment/Jenkinsfile_ArgoCD +++ /dev/null @@ -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 파이프라인 중 오류가 발생했습니다." - } - } -} \ No newline at end of file diff --git a/deployment/Jenkinsfile_backup b/deployment/Jenkinsfile_backup new file mode 100644 index 0000000..8118388 --- /dev/null +++ b/deployment/Jenkinsfile_backup @@ -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 + """ + } + } + } +} \ No newline at end of file diff --git a/deployment/deploy_env_vars b/deployment/deploy_env_vars index e449517..0463f58 100644 --- a/deployment/deploy_env_vars +++ b/deployment/deploy_env_vars @@ -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 \ No newline at end of file +gzip_compression_level=6 diff --git a/public/runtime-env.js b/public/runtime-env.js index 61dad8f..d1062c6 100644 --- a/public/runtime-env.js +++ b/public/runtime-env.js @@ -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', diff --git a/src/main.js b/src/main.js index f6aa27c..bb58e4b 100644 --- a/src/main.js +++ b/src/main.js @@ -2,7 +2,8 @@ /** * AI 마케팅 서비스 - 메인 앱 진입점 * Vue 3 + Vuetify 3 기반 애플리케이션 초기화 - */ + * +*/ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' diff --git a/src/services/api.js b/src/services/api.js index 6ac5977..e242f7c 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -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' } } diff --git a/src/services/content.js b/src/services/content.js index 12eb44f..781cad2 100644 --- a/src/services/content.js +++ b/src/services/content.js @@ -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' } diff --git a/src/views/ContentCreationView.vue b/src/views/ContentCreationView.vue index 4368574..8750af1 100644 --- a/src/views/ContentCreationView.vue +++ b/src/views/ContentCreationView.vue @@ -1,4 +1,4 @@ -//* src/views/ContentCreationView.vue - 수정된 완전한 파일 +//* src/views/ContentCreationView.vue - 완전 통합 버전 - + + + + @update:model-value="handleTargetTypeChange" + > + + - + - - + + @@ -388,7 +432,7 @@
- +
- +
@@ -467,7 +511,7 @@ mdi-content-copy 복사 @@ -504,11 +548,11 @@ - +

콘텐츠

- +
- +
홍보 대상 - + 이벤트명 + + + 메뉴명 + @@ -639,7 +689,7 @@ - +

AI가 콘텐츠를 생성 중입니다

@@ -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(//gi, '\n') //
태그를 줄바꿈으로 + .replace(/<\/p>/gi, '\n\n') //

태그를 두 줄바꿈으로 + .replace(/<[^>]*>/g, '') // 모든 HTML 태그 제거 + .replace(/ /g, ' ') //   를 공백으로 + .replace(/&/g, '&') // & 를 &로 + .replace(/</g, '<') // < 를 <로 + .replace(/>/g, '>') // > 를 >로 + .replace(/"/g, '"') // " 를 "로 + .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 }) - \ No newline at end of file + \ No newline at end of file diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue index 6a18c62..dab0d26 100644 --- a/src/views/DashboardView.vue +++ b/src/views/DashboardView.vue @@ -232,14 +232,7 @@

맞춤형 마케팅 제안

- +
@@ -252,7 +245,7 @@
- +
@@ -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) // 실패 시 기본 차트 데이터 유지 diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index a1cfd37..ef9a0ee 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -29,7 +29,7 @@ @keyup.enter="handleLogin" /> - + -
데모 계정 정보
- 아이디: user01 + 아이디: test
- 비밀번호: passw0rd + 비밀번호: test1234!
데모 계정 자동 입력 @@ -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 = '' } diff --git a/src/views/StoreManagementView.vue b/src/views/StoreManagementView.vue index a24f8c2..046ab9c 100644 --- a/src/views/StoreManagementView.vue +++ b/src/views/StoreManagementView.vue @@ -78,17 +78,42 @@ + - - - +
+ + + + + + +
+ {{ getStoreEmoji(storeInfo.businessType) }} +
+
+

{{ storeInfo.storeName }}

{{ storeInfo.businessType }}

+ @@ -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' +} \ No newline at end of file