diff --git a/deployment/Jenkinsfile b/deployment/Jenkinsfile new file mode 100644 index 0000000..8118388 --- /dev/null +++ b/deployment/Jenkinsfile @@ -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/Jenkinsfile_ArgoCD b/deployment/Jenkinsfile_ArgoCD new file mode 100644 index 0000000..0c6aec3 --- /dev/null +++ b/deployment/Jenkinsfile_ArgoCD @@ -0,0 +1,136 @@ +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/container/Dockerfile-smarketing-frontend b/deployment/container/Dockerfile-smarketing-frontend new file mode 100644 index 0000000..d2c8ede --- /dev/null +++ b/deployment/container/Dockerfile-smarketing-frontend @@ -0,0 +1,58 @@ +# Build stage +FROM node:20-slim AS builder + +ARG PROJECT_FOLDER +ARG VUE_APP_AUTH_URL +ARG VUE_APP_MEMBER_URL +ARG VUE_APP_STORE_URL +ARG VUE_APP_MENU_URL +ARG VUE_APP_SALES_URL +ARG VUE_APP_CONTENT_URL +ARG VUE_APP_RECOMMEND_URL + +ENV NODE_ENV=development +ENV VUE_APP_AUTH_URL=${VUE_APP_AUTH_URL} +ENV VUE_APP_MEMBER_URL=${VUE_APP_MEMBER_URL} +ENV VUE_APP_STORE_URL=${VUE_APP_STORE_URL} +ENV VUE_APP_MENU_URL=${VUE_APP_MENU_URL} +ENV VUE_APP_SALES_URL=${VUE_APP_SALES_URL} +ENV VUE_APP_CONTENT_URL=${VUE_APP_CONTENT_URL} +ENV VUE_APP_RECOMMEND_URL=${VUE_APP_RECOMMEND_URL} + +WORKDIR /app + +# Copy package files +COPY ${PROJECT_FOLDER}/package*.json ./ + +# Install all dependencies +RUN npm install + +# Copy source code +COPY ${PROJECT_FOLDER} . + +# ⚠️ 중요: public/runtime-env.js 삭제 (ConfigMap으로 대체) +RUN rm -f public/runtime-env.js + +# Build the application +RUN NODE_ENV=production npm run build + +# ⚠️ 빌드된 파일에서도 runtime-env.js 삭제 +RUN rm -f dist/runtime-env.js + +# Production stage +FROM nginx:alpine AS production + +ARG EXPORT_PORT=18080 + +# Copy built files from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY deployment/container/nginx.conf /etc/nginx/conf.d/default.conf + +# ⚠️ 더미 runtime-env.js 생성 (ConfigMap으로 덮어씌워질 예정) +RUN echo "console.log('Runtime config will be loaded from ConfigMap');" > /usr/share/nginx/html/runtime-env.js + +EXPOSE ${EXPORT_PORT} + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/deployment/container/nginx.conf b/deployment/container/nginx.conf new file mode 100644 index 0000000..65b4a14 --- /dev/null +++ b/deployment/container/nginx.conf @@ -0,0 +1,97 @@ +server { + listen 18080; + server_name localhost; + root /usr/share/nginx/html; + index index.html index.htm; + + # 에러 페이지 설정 + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + + # 로깅 설정 (디버깅용) + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log debug; + + # ⚠️ 중요: runtime-env.js 파일 특별 처리 + location = /runtime-env.js { + try_files $uri $uri/ =404; + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Content-Type "application/javascript"; + + # CORS 헤더 추가 + add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type"; + } + + # SPA 라우팅 처리 (Vue Router) + location / { + try_files $uri $uri/ /index.html; + + # HTML 파일은 캐시하지 않음 + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + } + } + + # 정적 자산 캐시 최적화 (runtime-env.js 제외) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + # runtime-env.js는 위에서 별도 처리되므로 제외 + if ($uri = "/runtime-env.js") { + break; + } + + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json + application/xml + image/svg+xml; + + # 보안 헤더 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' http://smarketing.20.249.184.228.nip.io https://smarketing.20.249.184.228.nip.io" always; + + # 헬스체크 엔드포인트 + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # 파비콘 캐시 + location /favicon.ico { + expires 1M; + access_log off; + log_not_found off; + } + + # 숨겨진 파일 접근 차단 + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} \ No newline at end of file diff --git a/deployment/deploy.yaml.template b/deployment/deploy.yaml.template new file mode 100644 index 0000000..a7ce0a2 --- /dev/null +++ b/deployment/deploy.yaml.template @@ -0,0 +1,172 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: smarketing-frontend-config + namespace: ${namespace} +data: + runtime-env.js: | + console.log('=== RUNTIME-ENV.JS 로드됨 (배포 환경) ==='); + + window.__runtime_config__ = { + // 백엔드 API 구조에 맞게 URL 설정 + AUTH_URL: '${auth_url}', + MEMBER_URL: '${member_url}', + STORE_URL: '${store_url}', + MENU_URL: '${menu_url}', + SALES_URL: '${sales_url}', + CONTENT_URL: '${content_url}', + RECOMMEND_URL: '${recommend_url}', + + // Gateway URL (운영 환경) + GATEWAY_URL: 'http://${ingress_host}', + + // 기능 플래그 + FEATURES: { + ANALYTICS: true, + PUSH_NOTIFICATIONS: true, + SOCIAL_LOGIN: false, + MULTI_LANGUAGE: false, + API_HEALTH_CHECK: true, + }, + + // 환경 설정 (배포 환경) + ENV: 'production', + DEBUG: false, + + // API 타임아웃 설정 + API_TIMEOUT: 30000, + + // 재시도 설정 + RETRY_ATTEMPTS: 3, + RETRY_DELAY: 1000, + + // 버전 정보 + VERSION: '1.0.0', + BUILD_DATE: new Date().toISOString() + }; + + // 설정 검증 함수 + const validateConfig = () => { + const config = window.__runtime_config__; + const requiredUrls = ['AUTH_URL', 'STORE_URL', 'SALES_URL', 'RECOMMEND_URL']; + + for (const url of requiredUrls) { + if (!config[url]) { + console.error(`❌ [CONFIG] 필수 URL 누락: ${url}`); + return false; + } + } + + console.log('✅ [CONFIG] 설정 검증 완료'); + return true; + }; + + // 전역 설정 접근 함수 + window.getApiConfig = () => window.__runtime_config__; + window.getApiUrl = (serviceName) => { + const config = window.__runtime_config__; + const urlKey = `${serviceName.toUpperCase()}_URL`; + return config[urlKey] || null; + }; + + // 설정 검증 실행 + validateConfig(); + console.log('✅ [RUNTIME] 런타임 설정 로드 완료 (배포 환경)'); + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing-frontend + namespace: ${namespace} + labels: + app: smarketing-frontend +spec: + replicas: ${replicas} + selector: + matchLabels: + app: smarketing-frontend + template: + metadata: + labels: + app: smarketing-frontend + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing-frontend + image: ${smarketing_frontend_image_path} + imagePullPolicy: Always + ports: + - containerPort: ${export_port} + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + volumeMounts: + - name: runtime-config + mountPath: /usr/share/nginx/html/runtime-env.js + subPath: runtime-env.js + livenessProbe: + httpGet: + path: /health + port: ${export_port} + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: ${export_port} + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: runtime-config + configMap: + name: smarketing-frontend-config + +--- +apiVersion: v1 +kind: Service +metadata: + name: smarketing-frontend-service + namespace: ${namespace} + labels: + app: smarketing-frontend +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: ${export_port} + protocol: TCP + name: http + selector: + app: smarketing-frontend + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: smarketing-frontend-ingress + namespace: ${namespace} + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/ssl-redirect: "false" +spec: + rules: + - host: ${ingress_host} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: smarketing-frontend-service + port: + number: 80 \ No newline at end of file diff --git a/deployment/deploy_env_vars b/deployment/deploy_env_vars new file mode 100644 index 0000000..e449517 --- /dev/null +++ b/deployment/deploy_env_vars @@ -0,0 +1,68 @@ +# deployment/deploy_env_vars +# smarketing-frontend 배포 환경변수 설정 +# 이 파일을 실제 환경에 맞게 수정하세요 + +# Container Registry 설정 +registry=acrdigitalgarage02.azurecr.io +image_org=smarketing + +# Kubernetes 설정 +namespace=smarketing +replicas=1 +export_port=18080 + +# Gateway/Ingress 설정 (⭐ smarketing-backend와 동일한 IP 사용) +ingress_host=smarketing.20.249.184.228.nip.io + +# 리소스 설정 (프론트엔드에 맞게 조정) +resources_requests_cpu=128m # 프론트엔드는 CPU 사용량이 적음 +resources_requests_memory=128Mi # 메모리도 적게 사용 +resources_limits_cpu=512m # 제한도 낮게 설정 +resources_limits_memory=512Mi + +# API URLs (⭐ smarketing-backend ingress를 통해 라우팅) +# 백엔드 서비스별 API 경로들 +auth_url=http://smarketing.20.249.184.228.nip.io/api/auth +member_url=http://smarketing.20.249.184.228.nip.io/api/member +store_url=http://smarketing.20.249.184.228.nip.io/api/store +menu_url=http://smarketing.20.249.184.228.nip.io/api/menu +sales_url=http://smarketing.20.249.184.228.nip.io/api/sales +content_url=http://smarketing.20.249.184.228.nip.io/api/content +recommend_url=http://smarketing.20.249.184.228.nip.io/api/recommend + +# Frontend 이미지 경로 설정 +smarketing_frontend_image_path=${registry}/${image_org}/smarketing-frontend:latest + +# GitHub 설정 +github_org=won-ktds +teamid=smarketing + +# 환경 플래그 +environment=production +debug_mode=false + +# SSL/TLS 설정 (필요시) +ssl_enabled=false +ssl_redirect=false + +# 로깅 레벨 +log_level=info + +# 헬스체크 설정 +health_check_path=/health +health_check_interval=30s +health_check_timeout=5s +health_check_retries=3 + +# 보안 설정 +security_headers_enabled=true +cors_enabled=true +allowed_origins=* + +# 캐시 설정 +static_cache_enabled=true +static_cache_duration=1y + +# 압축 설정 +gzip_enabled=true +gzip_compression_level=6 \ No newline at end of file diff --git a/deployment/manifest/deployment.yaml b/deployment/manifest/deployment.yaml new file mode 100644 index 0000000..6d19513 --- /dev/null +++ b/deployment/manifest/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing-frontend + namespace: ${namespace} + labels: + app: smarketing-frontend + version: v1 +spec: + replicas: ${replicas} + selector: + matchLabels: + app: smarketing-frontend + template: + metadata: + labels: + app: smarketing-frontend + version: v1 + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing-frontend + image: ${smarketing_frontend_image_path} + imagePullPolicy: Always + ports: + - containerPort: ${export_port} + name: http + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + volumeMounts: + - name: runtime-config + mountPath: /usr/share/nginx/html/runtime-env.js + subPath: runtime-env.js + readOnly: true + env: + - name: NGINX_PORT + value: "${export_port}" + livenessProbe: + httpGet: + path: /health + port: ${export_port} + scheme: HTTP + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + successThreshold: 1 + readinessProbe: + httpGet: + path: /health + port: ${export_port} + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + successThreshold: 1 + # 시작 프로브 추가 (컨테이너 시작 시간이 오래 걸릴 수 있음) + startupProbe: + httpGet: + path: /health + port: ${export_port} + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 30 + successThreshold: 1 + volumes: + - name: runtime-config + configMap: + name: smarketing-frontend-config + defaultMode: 0644 + # 배포 전략 설정 + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + # 재시작 정책 + restartPolicy: Always + # DNS 정책 + dnsPolicy: ClusterFirst \ No newline at end of file diff --git a/deployment/manifest/frontend-configmap.yaml b/deployment/manifest/frontend-configmap.yaml new file mode 100644 index 0000000..b42eff2 --- /dev/null +++ b/deployment/manifest/frontend-configmap.yaml @@ -0,0 +1,74 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: smarketing-frontend-config + namespace: ${namespace} +data: + runtime-env.js: | + console.log('=== RUNTIME-ENV.JS LOADED (PRODUCTION) ==='); + + window.__runtime_config__ = { + // Backend API URLs using ingress host + AUTH_URL: 'http://${ingress_host}/api/auth', + MEMBER_URL: 'http://${ingress_host}/api/member', + STORE_URL: 'http://${ingress_host}/api/store', + MENU_URL: 'http://${ingress_host}/api/menu', + SALES_URL: 'http://${ingress_host}/api/sales', + CONTENT_URL: 'http://${ingress_host}/api/content', + RECOMMEND_URL: 'http://${ingress_host}/api/recommend', + + // Gateway URL (production environment) + GATEWAY_URL: 'http://${ingress_host}', + + // Feature flags + FEATURES: { + ANALYTICS: true, + PUSH_NOTIFICATIONS: true, + SOCIAL_LOGIN: false, + MULTI_LANGUAGE: false, + API_HEALTH_CHECK: true, + }, + + // Environment settings (production) + ENV: 'production', + DEBUG: false, + + // API timeout settings + API_TIMEOUT: 30000, + + // Retry settings + RETRY_ATTEMPTS: 3, + RETRY_DELAY: 1000, + + // Version info + VERSION: '1.0.0', + BUILD_DATE: new Date().toISOString() + }; + + // Config validation function + const validateConfig = () => { + const config = window.__runtime_config__; + const requiredUrls = ['AUTH_URL', 'STORE_URL', 'SALES_URL', 'RECOMMEND_URL']; + + for (const url of requiredUrls) { + if (!config[url]) { + console.error('Missing required URL: ' + url); + return false; + } + } + + console.log('Config validation completed'); + return true; + }; + + // Global config access functions + window.getApiConfig = () => window.__runtime_config__; + window.getApiUrl = (serviceName) => { + const config = window.__runtime_config__; + const urlKey = serviceName.toUpperCase() + '_URL'; + return config[urlKey] || null; + }; + + // Execute validation + validateConfig(); + console.log('Runtime config loaded successfully (PRODUCTION)'); \ No newline at end of file diff --git a/deployment/manifest/service.yaml b/deployment/manifest/service.yaml new file mode 100644 index 0000000..f6c623f --- /dev/null +++ b/deployment/manifest/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: smarketing-frontend-service + namespace: ${namespace} + labels: + app: smarketing-frontend +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: ${export_port} + protocol: TCP + selector: + app: smarketing-frontend \ No newline at end of file diff --git a/index.html b/index.html index e81c5da..7dc5153 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,58 @@ - + - - Vite App + + + + + + + + + + + + + + + + + + + + + + + + + + AI 마케팅 - 소상공인을 위한 스마트 마케팅 솔루션
+ + + + + diff --git a/package.json b/package.json index 4df90f0..5f0ed5e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "ai-marketing-frontend", + "name": "smarketing-frontend", "version": "1.0.0", "private": true, "type": "module", @@ -21,6 +21,7 @@ "@vitejs/plugin-vue": "^5.0.0", "vite": "^5.0.0", "vite-plugin-vuetify": "^2.0.0", - "sass": "^1.69.0" + "sass": "^1.69.0", + "eslint": "^8.55.0" } } \ No newline at end of file diff --git a/public/index.html b/public/index.html index a50b512..7dc5153 100644 --- a/public/index.html +++ b/public/index.html @@ -1,4 +1,3 @@ -//* public/index.html @@ -29,14 +28,14 @@ rel="stylesheet" /> - test + + + + AI 마케팅 - 소상공인을 위한 스마트 마케팅 솔루션
- - - diff --git a/public/runtime-env.js b/public/runtime-env.js index 0dff287..61dad8f 100644 --- a/public/runtime-env.js +++ b/public/runtime-env.js @@ -1,44 +1,88 @@ -//* public/runtime-env.js - 백엔드 API 경로에 맞게 수정 -console.log('=== RUNTIME-ENV.JS 로드됨 ==='); +//* public/runtime-env.js - Production environment priority configuration +console.log('=== RUNTIME-ENV.JS LOADED ==='); + +// Production environment detection function +const isProduction = () => { + // Production environment detection logic + return window.location.hostname !== 'localhost' && + window.location.hostname !== '127.0.0.1' && + !window.location.hostname.includes('dev'); +}; + +// Default ingress host setting (from deploy_env_vars) +const DEFAULT_INGRESS_HOST = 'smarketing.20.249.184.228.nip.io'; + +// Environment-specific API URL configuration +const getBaseUrl = () => { + if (isProduction()) { + // Production: use ingress host + return `http://${DEFAULT_INGRESS_HOST}`; + } else { + // Development: use localhost + return ''; + } +}; + +const baseUrl = getBaseUrl(); window.__runtime_config__ = { - // ⚠️ 수정: 백엔드 API 구조에 맞게 URL 설정 - AUTH_URL: 'http://localhost:8081/api/auth', - MEMBER_URL: 'http://localhost:8081/api/member', - STORE_URL: 'http://localhost:8082/api/store', - MENU_URL: 'http://localhost:8082/api/menu', - SALES_URL: 'http://localhost:8082/api/sales', // store 서비스 - CONTENT_URL: 'http://localhost:8083/api/content', - RECOMMEND_URL: 'http://localhost:8084/api/recommendations', // ⚠️ 수정: 올바른 경로 + // Use ingress host in production, localhost in development + AUTH_URL: isProduction() ? + `${baseUrl}/api/auth` : + 'http://localhost:8081/api/auth', + + MEMBER_URL: isProduction() ? + `${baseUrl}/api/member` : + 'http://localhost:8081/api/member', + + STORE_URL: isProduction() ? + `${baseUrl}/api/store` : + 'http://localhost:8082/api/store', + + MENU_URL: isProduction() ? + `${baseUrl}/api/menu` : + 'http://localhost:8082/api/menu', + + SALES_URL: isProduction() ? + `${baseUrl}/api/sales` : + 'http://localhost:8082/api/sales', + + CONTENT_URL: isProduction() ? + `${baseUrl}/api/content` : + 'http://localhost:8083/api/content', + + RECOMMEND_URL: isProduction() ? + `${baseUrl}/api/recommend` : + 'http://localhost:8084/api/recommendations', - // Gateway URL (운영 환경용) - GATEWAY_URL: 'http://20.1.2.3', + // Gateway URL + GATEWAY_URL: isProduction() ? baseUrl : 'http://20.1.2.3', - // 기능 플래그 + // Feature flags FEATURES: { ANALYTICS: true, PUSH_NOTIFICATIONS: true, SOCIAL_LOGIN: false, MULTI_LANGUAGE: false, - API_HEALTH_CHECK: true, // ⚠️ 추가 + API_HEALTH_CHECK: true, }, - // 환경 설정 - ENV: 'development', - DEBUG: true, + // Environment settings + ENV: isProduction() ? 'production' : 'development', + DEBUG: !isProduction(), - // ⚠️ 추가: API 타임아웃 설정 + // API timeout settings API_TIMEOUT: 30000, - // ⚠️ 추가: 재시도 설정 + // Retry settings RETRY_ATTEMPTS: 3, RETRY_DELAY: 1000, - // 버전 정보 + // Version information VERSION: '1.0.0', BUILD_DATE: new Date().toISOString(), - // ⚠️ 추가: 백엔드 서비스 포트 정보 (디버깅용) + // Backend service port information (for debugging) BACKEND_PORTS: { AUTH: 8081, STORE: 8082, @@ -47,39 +91,41 @@ window.__runtime_config__ = { } }; -// ⚠️ 추가: 설정 검증 함수 +// Configuration validation function const validateConfig = () => { const config = window.__runtime_config__; const requiredUrls = ['AUTH_URL', 'STORE_URL', 'SALES_URL', 'RECOMMEND_URL']; for (const url of requiredUrls) { if (!config[url]) { - console.error(`❌ [CONFIG] 필수 URL 누락: ${url}`); + console.error(`Missing required URL: ${url}`); return false; } } - console.log('✅ [CONFIG] 설정 검증 완료'); + console.log('Config validation completed'); return true; }; -// ⚠️ 추가: 개발 환경에서만 상세 로깅 +// Environment-specific detailed logging if (window.__runtime_config__.DEBUG) { - console.log('=== 백엔드 API URLs ==='); - console.log('🔐 AUTH_URL:', window.__runtime_config__.AUTH_URL); - console.log('🏪 STORE_URL:', window.__runtime_config__.STORE_URL); - console.log('📊 SALES_URL:', window.__runtime_config__.SALES_URL); - console.log('🤖 RECOMMEND_URL:', window.__runtime_config__.RECOMMEND_URL); - console.log('📄 CONTENT_URL:', window.__runtime_config__.CONTENT_URL); + console.log('=== Current Environment Info ==='); + console.log('Environment:', window.__runtime_config__.ENV); + console.log('Hostname:', window.location.hostname); + console.log('Is Production:', isProduction()); - console.log('=== 설정 상세 정보 ==='); - console.log('전체 설정:', window.__runtime_config__); + console.log('=== Backend API URLs ==='); + console.log('AUTH_URL:', window.__runtime_config__.AUTH_URL); + console.log('STORE_URL:', window.__runtime_config__.STORE_URL); + console.log('SALES_URL:', window.__runtime_config__.SALES_URL); + console.log('RECOMMEND_URL:', window.__runtime_config__.RECOMMEND_URL); + console.log('CONTENT_URL:', window.__runtime_config__.CONTENT_URL); - // 설정 검증 실행 - validateConfig(); + console.log('=== Detailed Configuration ==='); + console.log('Full config:', window.__runtime_config__); } -// ⚠️ 추가: 전역 설정 접근 함수 +// Global configuration access functions window.getApiConfig = () => window.__runtime_config__; window.getApiUrl = (serviceName) => { const config = window.__runtime_config__; @@ -87,4 +133,7 @@ window.getApiUrl = (serviceName) => { return config[urlKey] || null; }; -console.log('✅ [RUNTIME] 런타임 설정 로드 완료'); \ No newline at end of file +// Execute configuration validation +validateConfig(); + +console.log(`Runtime configuration loaded successfully (${window.__runtime_config__.ENV} environment)`); \ No newline at end of file diff --git a/src/services/menu.js b/src/services/menu.js index 1ecb3e7..294451a 100644 --- a/src/services/menu.js +++ b/src/services/menu.js @@ -1,5 +1,5 @@ -//* src/services/menu.js - 백엔드 수정 없이 프론트엔드만 수정 -import { menuApi, apiWithImage, handleApiError, formatSuccessResponse } from './api.js' +//* src/services/menu.js - apiWithImage 제거하고 menuApi로 통일 +import { menuApi, handleApiError, formatSuccessResponse } from './api.js' /** * 메뉴 관련 API 서비스 @@ -48,7 +48,7 @@ class MenuService { } /** - * 메뉴 등록 (createMenu) + * 메뉴 등록 * @param {Object} menuData - 메뉴 정보 * @returns {Promise} 등록 결과 */ @@ -79,95 +79,9 @@ class MenuService { } } catch (error) { console.error('메뉴 등록 실패:', error) - return handleApiError(error) - } - } - - /** - * 메뉴 등록 (registerMenu 별칭) - * @param {Object} menuData - 메뉴 정보 - * @returns {Promise} 등록 결과 - */ - async registerMenu(menuData) { - return await this.createMenu(menuData) - } - - /** - * 메뉴 수정 - * @param {number} menuId - 메뉴 ID - * @param {Object} menuData - 수정할 메뉴 정보 - * @returns {Promise} 수정 결과 - */ -async updateMenu(menuId, menuData) { - try { - console.log('=== 메뉴 수정 API 호출 ===') - console.log('메뉴 ID:', menuId, '타입:', typeof menuId) - console.log('원본 수정 데이터:', menuData) - - if (!menuId || menuId === 'undefined') { - throw new Error('올바른 메뉴 ID가 필요합니다') - } - - const numericMenuId = parseInt(menuId) - if (isNaN(numericMenuId)) { - throw new Error('메뉴 ID는 숫자여야 합니다') - } - - // 데이터 검증 및 정리 - const menuName = menuData.menuName || menuData.name - const category = menuData.category - const price = menuData.price - const description = menuData.description || '' - - // 필수 필드 검증 - if (!menuName || !category || price === undefined || price === null) { - console.error('필수 필드 누락:', { menuName, category, price, description }) - throw new Error('메뉴명, 카테고리, 가격은 필수 입력 사항입니다') - } - - // 가격 검증 (숫자이고 0 이상) - const numericPrice = parseInt(price) - if (isNaN(numericPrice) || numericPrice < 0) { - throw new Error('올바른 가격을 입력해주세요') - } - - // 백엔드 MenuUpdateRequest DTO에 맞는 데이터 구조 - const requestData = { - menuName: menuName.trim(), - category: category.trim(), - price: numericPrice, - description: description.trim() - } - - console.log('검증된 백엔드 전송 데이터:', requestData) - console.log('✅ 검증된 메뉴 ID:', numericMenuId) - - // PUT /api/menu/{menuId} - const response = await menuApi.put(`/${numericMenuId}`, requestData) - - console.log('메뉴 수정 API 응답:', response.data) - - if (response.data && response.data.status === 200) { - return formatSuccessResponse(response.data.data, '메뉴가 성공적으로 수정되었습니다.') - } else { - throw new Error(response.data.message || '메뉴 수정에 실패했습니다.') - } - } catch (error) { - console.error('메뉴 수정 실패:', error) - - // HTTP 응답 에러 상세 디버깅 - if (error.response) { - console.error('=== HTTP 응답 에러 상세 ===') - console.error('상태 코드:', error.response.status) - console.error('상태 텍스트:', error.response.statusText) - console.error('응답 데이터:', error.response.data) - console.error('요청 URL:', error.config?.url) - console.error('요청 메서드:', error.config?.method) - console.error('요청 데이터:', error.config?.data) - // 400 에러 (잘못된 요청) 처리 - if (error.response.status === 400) { - const errorMessage = error.response.data?.message || '입력 데이터가 올바르지 않습니다.' + if (error.response?.status === 400) { + const errorMessage = error.response.data?.message || 'validation 에러가 발생했습니다.' console.error('백엔드 validation 에러:', errorMessage) return { @@ -177,8 +91,7 @@ async updateMenu(menuId, menuData) { } } - // 500 오류 처리 - if (error.response.status === 500) { + if (error.response?.status === 500) { const errorMessage = error.response.data?.message || '서버 내부 오류가 발생했습니다.' console.error('백엔드 에러 메시지:', errorMessage) @@ -188,15 +101,58 @@ async updateMenu(menuId, menuData) { error: error.response.data } } + + return handleApiError(error) } - - return handleApiError(error) } -} - /** - * 메뉴 이미지 업로드 + * 메뉴 수정 + * @param {number} menuId - 메뉴 ID + * @param {Object} menuData - 수정할 메뉴 정보 + * @returns {Promise} 수정 결과 + */ + async updateMenu(menuId, menuData) { + try { + console.log('=== 메뉴 수정 API 호출 ===') + console.log('메뉴 ID:', menuId, '수정 데이터:', menuData) + + if (!menuId || menuId === 'undefined') { + throw new Error('올바른 메뉴 ID가 필요합니다') + } + + const numericMenuId = parseInt(menuId) + if (isNaN(numericMenuId)) { + throw new Error('메뉴 ID는 숫자여야 합니다') + } + + const requestData = { + menuName: menuData.menuName || menuData.name, + category: menuData.category, + price: parseInt(menuData.price) || 0, + description: menuData.description || '' + } + + console.log('백엔드 전송 데이터:', requestData) + + // PUT /api/menu/{menuId} + const response = await menuApi.put(`/${numericMenuId}`, requestData) + + console.log('메뉴 수정 API 응답:', response.data) + + if (response.data && response.data.status === 200) { + return formatSuccessResponse(response.data.data, '메뉴가 성공적으로 수정되었습니다.') + } else { + throw new Error(response.data.message || '메뉴 수정에 실패했습니다.') + } + } catch (error) { + console.error('메뉴 수정 실패:', error) + return handleApiError(error) + } + } + + /** + * 메뉴 이미지 업로드 (menuApi 사용) * @param {number} menuId - 메뉴 ID * @param {File} file - 이미지 파일 * @returns {Promise} 업로드 결과 @@ -225,8 +181,8 @@ async updateMenu(menuId, menuData) { console.log('이미지 업로드 요청 - 메뉴 ID:', numericMenuId) - // POST /api/images/menu/{menuId} - const response = await apiWithImage.post(`/images/menu/${numericMenuId}`, formData, { + // POST /api/menu/images/{menuId} (menuApi 사용) + const response = await menuApi.post(`/images/${numericMenuId}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } @@ -288,222 +244,4 @@ export default menuService // 디버깅을 위한 전역 노출 (개발 환경에서만) if (process.env.NODE_ENV === 'development') { window.menuService = menuService -} - -//* src/views/StoreManagementView.vue의 수정된 스크립트 부분 -//