Merge branch 'main' into front
This commit is contained in:
commit
d85bbf903d
160
deployment/Jenkinsfile
vendored
Normal file
160
deployment/Jenkinsfile
vendored
Normal file
@ -0,0 +1,160 @@
|
||||
// deployment/Jenkinsfile
|
||||
def PIPELINE_ID = "${env.BUILD_NUMBER}"
|
||||
|
||||
def getImageTag() {
|
||||
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
|
||||
def currentDate = new Date()
|
||||
return dateFormat.format(currentDate)
|
||||
}
|
||||
|
||||
podTemplate(
|
||||
label: "${PIPELINE_ID}",
|
||||
serviceAccount: 'jenkins',
|
||||
containers: [
|
||||
containerTemplate(name: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'),
|
||||
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
|
||||
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
|
||||
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
|
||||
],
|
||||
volumes: [
|
||||
emptyDirVolume(mountPath: '/root/.azure', memory: false),
|
||||
emptyDirVolume(mountPath: '/run/podman', memory: false)
|
||||
]
|
||||
) {
|
||||
node(PIPELINE_ID) {
|
||||
def props
|
||||
def imageTag = getImageTag()
|
||||
def manifest = "deploy.yaml"
|
||||
def namespace
|
||||
|
||||
stage("Get Source") {
|
||||
checkout scm
|
||||
|
||||
// 환경변수 파일 확인 및 읽기
|
||||
if (!fileExists('deployment/deploy_env_vars')) {
|
||||
error "deployment/deploy_env_vars 파일이 없습니다!"
|
||||
}
|
||||
|
||||
props = readProperties file: "deployment/deploy_env_vars"
|
||||
namespace = "${props.namespace}"
|
||||
|
||||
// 필수 환경변수 검증
|
||||
if (!props.registry || !props.image_org || !props.namespace) {
|
||||
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
|
||||
}
|
||||
|
||||
echo "Registry: ${props.registry}"
|
||||
echo "Image Org: ${props.image_org}"
|
||||
echo "Namespace: ${namespace}"
|
||||
echo "Image Tag: ${imageTag}"
|
||||
}
|
||||
|
||||
stage("Setup AKS") {
|
||||
container('azure-cli') {
|
||||
withCredentials([azureServicePrincipal('azure-credentials')]) {
|
||||
sh """
|
||||
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
|
||||
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
|
||||
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build & Push Image') {
|
||||
container('podman') {
|
||||
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: 'acr-credentials',
|
||||
usernameVariable: 'ACR_USERNAME',
|
||||
passwordVariable: 'ACR_PASSWORD'
|
||||
)]) {
|
||||
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
|
||||
|
||||
sh """
|
||||
echo "=========================================="
|
||||
echo "Building smarketing-frontend"
|
||||
echo "Image Tag: ${imageTag}"
|
||||
echo "Image Path: ${imagePath}"
|
||||
echo "=========================================="
|
||||
|
||||
# ACR 로그인
|
||||
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
|
||||
|
||||
# Docker 이미지 빌드
|
||||
podman build \\
|
||||
--build-arg PROJECT_FOLDER="." \\
|
||||
--build-arg VUE_APP_AUTH_URL="${props.auth_url}" \\
|
||||
--build-arg VUE_APP_MEMBER_URL="${props.member_url}" \\
|
||||
--build-arg VUE_APP_STORE_URL="${props.store_url}" \\
|
||||
--build-arg VUE_APP_MENU_URL="${props.menu_url}" \\
|
||||
--build-arg VUE_APP_SALES_URL="${props.sales_url}" \\
|
||||
--build-arg VUE_APP_CONTENT_URL="${props.content_url}" \\
|
||||
--build-arg VUE_APP_RECOMMEND_URL="${props.recommend_url}" \\
|
||||
--build-arg BUILD_FOLDER="deployment/container" \\
|
||||
--build-arg EXPORT_PORT="${props.export_port}" \\
|
||||
-f deployment/container/Dockerfile-smarketing-frontend \\
|
||||
-t ${imagePath} .
|
||||
|
||||
# 이미지 푸시
|
||||
podman push ${imagePath}
|
||||
|
||||
echo "Image pushed successfully: ${imagePath}"
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Generate & Apply Manifest') {
|
||||
container('envsubst') {
|
||||
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
|
||||
|
||||
sh """
|
||||
export namespace=${namespace}
|
||||
export smarketing_frontend_image_path=${imagePath}
|
||||
export replicas=${props.replicas}
|
||||
export export_port=${props.export_port}
|
||||
export ingress_host=${props.ingress_host}
|
||||
export resources_requests_cpu=${props.resources_requests_cpu}
|
||||
export resources_requests_memory=${props.resources_requests_memory}
|
||||
export resources_limits_cpu=${props.resources_limits_cpu}
|
||||
export resources_limits_memory=${props.resources_limits_memory}
|
||||
|
||||
# API URLs도 export (혹시 사용할 수도 있으니)
|
||||
export auth_url=${props.auth_url}
|
||||
export member_url=${props.member_url}
|
||||
export store_url=${props.store_url}
|
||||
export menu_url=${props.menu_url}
|
||||
export sales_url=${props.sales_url}
|
||||
export content_url=${props.content_url}
|
||||
export recommend_url=${props.recommend_url}
|
||||
|
||||
echo "=== 환경변수 확인 ==="
|
||||
echo "namespace: \$namespace"
|
||||
echo "ingress_host: \$ingress_host"
|
||||
echo "export_port: \$export_port"
|
||||
echo "========================="
|
||||
|
||||
envsubst < deployment/${manifest}.template > deployment/${manifest}
|
||||
echo "Generated manifest file:"
|
||||
cat deployment/${manifest}
|
||||
"""
|
||||
}
|
||||
|
||||
container('azure-cli') {
|
||||
sh """
|
||||
kubectl apply -f deployment/${manifest}
|
||||
|
||||
echo "Waiting for deployment to be ready..."
|
||||
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing-frontend --timeout=300s
|
||||
|
||||
echo "Deployment completed successfully!"
|
||||
kubectl -n ${namespace} get pods -l app=smarketing-frontend
|
||||
kubectl -n ${namespace} get svc smarketing-frontend-service
|
||||
kubectl -n ${namespace} get ingress
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
136
deployment/Jenkinsfile_ArgoCD
Normal file
136
deployment/Jenkinsfile_ArgoCD
Normal file
@ -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 파이프라인 중 오류가 발생했습니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
58
deployment/container/Dockerfile-smarketing-frontend
Normal file
58
deployment/container/Dockerfile-smarketing-frontend
Normal file
@ -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;"]
|
||||
97
deployment/container/nginx.conf
Normal file
97
deployment/container/nginx.conf
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
172
deployment/deploy.yaml.template
Normal file
172
deployment/deploy.yaml.template
Normal file
@ -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
|
||||
68
deployment/deploy_env_vars
Normal file
68
deployment/deploy_env_vars
Normal file
@ -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
|
||||
89
deployment/manifest/deployment.yaml
Normal file
89
deployment/manifest/deployment.yaml
Normal file
@ -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
|
||||
74
deployment/manifest/frontend-configmap.yaml
Normal file
74
deployment/manifest/frontend-configmap.yaml
Normal file
@ -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)');
|
||||
15
deployment/manifest/service.yaml
Normal file
15
deployment/manifest/service.yaml
Normal file
@ -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
|
||||
51
index.html
51
index.html
@ -1,13 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<meta name="description" content="소상공인을 위한 AI 마케팅 솔루션" />
|
||||
<meta name="keywords" content="AI, 마케팅, 소상공인, 콘텐츠, 자동화" />
|
||||
<meta name="author" content="AI 마케팅 팀" />
|
||||
|
||||
<!-- PWA 설정 -->
|
||||
<meta name="theme-color" content="#1976D2" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="AI 마케팅" />
|
||||
|
||||
<!-- 파비콘 -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/images/logo192.png" />
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- 폰트 사전 로드 -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- 런타임 환경 설정 -->
|
||||
<script src="/runtime-env.js"></script>
|
||||
|
||||
<title>AI 마케팅 - 소상공인을 위한 스마트 마케팅 솔루션</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- 앱 스크립트 -->
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
<!-- 서비스 워커 등록 -->
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration)
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log('SW registration failed: ', registrationError)
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
//* public/index.html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
@ -29,14 +28,14 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title>test</title>
|
||||
<!-- 런타임 환경 설정 -->
|
||||
<script src="/runtime-env.js"></script>
|
||||
|
||||
<title>AI 마케팅 - 소상공인을 위한 스마트 마케팅 솔루션</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<!-- 런타임 환경 설정 -->
|
||||
<script src="/runtime-env.js"></script>
|
||||
|
||||
<!-- 앱 스크립트 -->
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
|
||||
|
||||
@ -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] 런타임 설정 로드 완료');
|
||||
// Execute configuration validation
|
||||
validateConfig();
|
||||
|
||||
console.log(`Runtime configuration loaded successfully (${window.__runtime_config__.ENV} environment)`);
|
||||
@ -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<Object>} 등록 결과
|
||||
*/
|
||||
@ -79,95 +79,9 @@ class MenuService {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메뉴 등록 실패:', error)
|
||||
return handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 등록 (registerMenu 별칭)
|
||||
* @param {Object} menuData - 메뉴 정보
|
||||
* @returns {Promise<Object>} 등록 결과
|
||||
*/
|
||||
async registerMenu(menuData) {
|
||||
return await this.createMenu(menuData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 수정
|
||||
* @param {number} menuId - 메뉴 ID
|
||||
* @param {Object} menuData - 수정할 메뉴 정보
|
||||
* @returns {Promise<Object>} 수정 결과
|
||||
*/
|
||||
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<Object>} 수정 결과
|
||||
*/
|
||||
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<Object>} 업로드 결과
|
||||
@ -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의 수정된 스크립트 부분
|
||||
// <script setup> 내부의 메뉴 관련 함수들만 수정
|
||||
|
||||
// 메뉴 상세 보기 함수 - 메뉴 목록 데이터 활용
|
||||
const viewMenuDetail = (menu) => {
|
||||
console.log('=== 메뉴 상세 보기 호출 ===')
|
||||
console.log('전달받은 메뉴 객체:', menu)
|
||||
|
||||
// 메뉴 ID 추출 (여러 형태 지원)
|
||||
const menuId = menu.menuId || menu.id
|
||||
|
||||
if (!menuId) {
|
||||
console.error('❌ 메뉴 ID를 찾을 수 없음:', menu)
|
||||
showSnackbar('메뉴 정보가 올바르지 않습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('✅ 사용할 메뉴 ID:', menuId)
|
||||
|
||||
// API 호출 없이 바로 메뉴 목록의 데이터 사용
|
||||
selectedMenu.value = {
|
||||
...menu,
|
||||
// 호환성을 위해 여러 형태 지원
|
||||
id: menuId,
|
||||
menuId: menuId,
|
||||
name: menu.menuName || menu.name,
|
||||
menuName: menu.menuName || menu.name
|
||||
}
|
||||
|
||||
console.log('✅ 메뉴 상세 정보 설정 완료:', selectedMenu.value)
|
||||
showMenuDetailDialog.value = true
|
||||
}
|
||||
|
||||
// 메뉴 수정 함수
|
||||
const editMenu = (menu) => {
|
||||
console.log('=== 메뉴 수정 호출 ===')
|
||||
console.log('전달받은 메뉴 객체:', menu)
|
||||
|
||||
// 메뉴 ID 추출 및 검증
|
||||
const menuId = menu.menuId || menu.id
|
||||
|
||||
console.log('추출된 메뉴 ID:', menuId, '타입:', typeof menuId)
|
||||
|
||||
if (!menuId) {
|
||||
console.error('❌ 메뉴 ID를 찾을 수 없음')
|
||||
showSnackbar('메뉴 정보가 올바르지 않습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('✅ 사용할 메뉴 ID:', menuId)
|
||||
|
||||
// 수정 모드로 설정
|
||||
menuEditMode.value = true
|
||||
|
||||
// 폼 데이터 설정 (여러 필드명 지원)
|
||||
menuFormData.value = {
|
||||
menuId: menuId,
|
||||
id: menuId, // 호환성
|
||||
menuName: menu.menuName || menu.name,
|
||||
name: menu.menuName || menu.name, // 호환성
|
||||
category: menu.category,
|
||||
price: menu.price,
|
||||
description: menu.description || '',
|
||||
imageUrl: menu.imageUrl
|
||||
}
|
||||
|
||||
console.log('✅ 수정 폼 데이터 설정 완료:', menuFormData.value)
|
||||
|
||||
// 다이얼로그 표시
|
||||
showMenuDialog.value = true
|
||||
}
|
||||
|
||||
// 메뉴 상세에서 수정 버튼 클릭
|
||||
const editFromDetail = () => {
|
||||
console.log('=== 메뉴 상세에서 수정 버튼 클릭 ===')
|
||||
console.log('selectedMenu.value:', selectedMenu.value)
|
||||
|
||||
if (!selectedMenu.value) {
|
||||
showSnackbar('선택된 메뉴가 없습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 메뉴 ID 검증
|
||||
const menuId = selectedMenu.value.menuId || selectedMenu.value.id
|
||||
if (!menuId) {
|
||||
showSnackbar('메뉴 ID를 찾을 수 없습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('✅ 수정할 메뉴 정보:', {
|
||||
id: menuId,
|
||||
name: selectedMenu.value.menuName || selectedMenu.value.name,
|
||||
category: selectedMenu.value.category
|
||||
})
|
||||
|
||||
// 상세 다이얼로그 닫기
|
||||
closeMenuDetail()
|
||||
|
||||
// 수정 모드로 전환
|
||||
editMenu(selectedMenu.value)
|
||||
}
|
||||
|
||||
// 상세 다이얼로그 닫기
|
||||
const closeMenuDetail = () => {
|
||||
console.log('=== 메뉴 상세 다이얼로그 닫기 ===')
|
||||
showMenuDetailDialog.value = false
|
||||
selectedMenu.value = null
|
||||
}
|
||||
|
||||
// 메뉴 저장 함수 - 이미지 업로드 분리
|
||||
const saveMenuWithImage = async () => {
|
||||
if (saving.value) return
|
||||
|
||||
console.log('=== 메뉴 저장 + 이미지 업로드 시작 ===')
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// 메뉴 서비스 임포트
|
||||
const { menuService } = await import('@/services/menu')
|
||||
|
||||
let menuResult
|
||||
|
||||
if (menuEditMode.value) {
|
||||
// 메뉴 수정 - PUT /api/menu/{menuId}
|
||||
const menuId = menuFormData.value.id || menuFormData.value.menuId
|
||||
if (!menuId) {
|
||||
showSnackbar('메뉴 ID가 없습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('메뉴 수정 API 호출, 메뉴 ID:', menuId)
|
||||
|
||||
// 메뉴 데이터 준비
|
||||
const menuData = {
|
||||
menuName: menuFormData.value.menuName || menuFormData.value.name,
|
||||
category: menuFormData.value.category,
|
||||
price: menuFormData.value.price,
|
||||
description: menuFormData.value.description || ''
|
||||
}
|
||||
|
||||
menuResult = await menuService.updateMenu(menuId, menuData)
|
||||
} else {
|
||||
// 새 메뉴 등록 - POST /api/menu/register
|
||||
const storeId = storeInfo.value?.storeId
|
||||
if (!storeId) {
|
||||
showSnackbar('매장 정보를 찾을 수 없습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 메뉴 데이터 준비 (매장 ID 포함)
|
||||
const menuData = {
|
||||
storeId: storeId,
|
||||
menuName: menuFormData.value.menuName || menuFormData.value.name,
|
||||
category: menuFormData.value.category,
|
||||
price: menuFormData.value.price,
|
||||
description: menuFormData.value.description || ''
|
||||
}
|
||||
|
||||
console.log('메뉴 등록 API 호출, 매장 ID:', storeId)
|
||||
menuResult = await menuService.createMenu(menuData)
|
||||
}
|
||||
|
||||
console.log('✅ 메뉴 저장 완료:', menuResult)
|
||||
|
||||
if (!menuResult.success) {
|
||||
showSnackbar(menuResult.message || '메뉴 저장에 실패했습니다', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
// 메뉴 저장 성공 후 이미지 업로드
|
||||
let imageResult = { success: true }
|
||||
|
||||
if (selectedImageFile.value) {
|
||||
console.log('=== 이미지 업로드 시작 ===')
|
||||
|
||||
// 등록된 메뉴의 ID 가져오기
|
||||
const menuId = menuEditMode.value
|
||||
? (menuFormData.value.id || menuFormData.value.menuId)
|
||||
: menuResult.data?.menuId
|
||||
|
||||
if (menuId) {
|
||||
console.log('이미지 업로드 - 메뉴 ID:', menuId)
|
||||
imageResult = await menuService.uploadMenuImage(menuId, selectedImageFile.value)
|
||||
console.log('이미지 업로드 결과:', imageResult)
|
||||
|
||||
if (!imageResult.success) {
|
||||
console.warn('이미지 업로드는 실패했지만 메뉴는 저장됨')
|
||||
showSnackbar('메뉴는 저장되었지만 이미지 업로드에 실패했습니다', 'warning')
|
||||
}
|
||||
} else {
|
||||
console.warn('메뉴 ID를 찾을 수 없어 이미지 업로드 생략')
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 메시지
|
||||
if (menuResult.success && imageResult.success) {
|
||||
showSnackbar(
|
||||
menuEditMode.value ? '메뉴가 수정되었습니다' : '메뉴가 등록되었습니다',
|
||||
'success'
|
||||
)
|
||||
}
|
||||
|
||||
// 다이얼로그 닫기 및 초기화
|
||||
showMenuDialog.value = false
|
||||
menuEditMode.value = false
|
||||
resetMenuForm()
|
||||
|
||||
// 메뉴 목록 새로고침
|
||||
await loadMenus()
|
||||
|
||||
} catch (error) {
|
||||
console.error('메뉴 저장 중 오류:', error)
|
||||
showSnackbar('저장 중 오류가 발생했습니다', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
@ -1,30 +1,36 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vuetify from 'vite-plugin-vuetify'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
vuetify({
|
||||
autoImport: true,
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true,
|
||||
open: true,
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['vue', 'vue-router', 'pinia'],
|
||||
vuetify: ['vuetify'],
|
||||
icons: ['@mdi/font'],
|
||||
},
|
||||
},
|
||||
},
|
||||
sourcemap: false,
|
||||
minify: 'esbuild' // terser 대신 esbuild 사용 (더 빠르고 별도 설치 불필요)
|
||||
},
|
||||
})
|
||||
server: {
|
||||
port: 3000,
|
||||
host: true
|
||||
},
|
||||
define: {
|
||||
'process.env.VUE_APP_AUTH_URL': JSON.stringify(process.env.VUE_APP_AUTH_URL),
|
||||
'process.env.VUE_APP_MEMBER_URL': JSON.stringify(process.env.VUE_APP_MEMBER_URL),
|
||||
'process.env.VUE_APP_STORE_URL': JSON.stringify(process.env.VUE_APP_STORE_URL),
|
||||
'process.env.VUE_APP_MENU_URL': JSON.stringify(process.env.VUE_APP_MENU_URL),
|
||||
'process.env.VUE_APP_SALES_URL': JSON.stringify(process.env.VUE_APP_SALES_URL),
|
||||
'process.env.VUE_APP_CONTENT_URL': JSON.stringify(process.env.VUE_APP_CONTENT_URL),
|
||||
'process.env.VUE_APP_RECOMMEND_URL': JSON.stringify(process.env.VUE_APP_RECOMMEND_URL)
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user