Merge branch 'main' into front

This commit is contained in:
yyoooona 2025-06-19 09:25:23 +09:00 committed by GitHub
commit d85bbf903d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1091 additions and 384 deletions

160
deployment/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,160 @@
// deployment/Jenkinsfile
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss')
def currentDate = new Date()
return dateFormat.format(currentDate)
}
podTemplate(
label: "${PIPELINE_ID}",
serviceAccount: 'jenkins',
containers: [
containerTemplate(name: 'node', image: 'node:20-slim', ttyEnabled: true, command: 'cat'),
containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true),
containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h')
],
volumes: [
emptyDirVolume(mountPath: '/root/.azure', memory: false),
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
stage("Get Source") {
checkout scm
// 환경변수 파일 확인 및 읽기
if (!fileExists('deployment/deploy_env_vars')) {
error "deployment/deploy_env_vars 파일이 없습니다!"
}
props = readProperties file: "deployment/deploy_env_vars"
namespace = "${props.namespace}"
// 필수 환경변수 검증
if (!props.registry || !props.image_org || !props.namespace) {
error "필수 환경변수가 누락되었습니다. registry, image_org, namespace를 확인하세요."
}
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
echo "Namespace: ${namespace}"
echo "Image Tag: ${imageTag}"
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
sh """
az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
"""
}
}
}
stage('Build & Push Image') {
container('podman') {
sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2'
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
passwordVariable: 'ACR_PASSWORD'
)]) {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
echo "=========================================="
echo "Building smarketing-frontend"
echo "Image Tag: ${imageTag}"
echo "Image Path: ${imagePath}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \\
--build-arg PROJECT_FOLDER="." \\
--build-arg VUE_APP_AUTH_URL="${props.auth_url}" \\
--build-arg VUE_APP_MEMBER_URL="${props.member_url}" \\
--build-arg VUE_APP_STORE_URL="${props.store_url}" \\
--build-arg VUE_APP_MENU_URL="${props.menu_url}" \\
--build-arg VUE_APP_SALES_URL="${props.sales_url}" \\
--build-arg VUE_APP_CONTENT_URL="${props.content_url}" \\
--build-arg VUE_APP_RECOMMEND_URL="${props.recommend_url}" \\
--build-arg BUILD_FOLDER="deployment/container" \\
--build-arg EXPORT_PORT="${props.export_port}" \\
-f deployment/container/Dockerfile-smarketing-frontend \\
-t ${imagePath} .
# 이미지 푸시
podman push ${imagePath}
echo "Image pushed successfully: ${imagePath}"
"""
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
def imagePath = "${props.registry}/${props.image_org}/smarketing-frontend:${imageTag}"
sh """
export namespace=${namespace}
export smarketing_frontend_image_path=${imagePath}
export replicas=${props.replicas}
export export_port=${props.export_port}
export ingress_host=${props.ingress_host}
export resources_requests_cpu=${props.resources_requests_cpu}
export resources_requests_memory=${props.resources_requests_memory}
export resources_limits_cpu=${props.resources_limits_cpu}
export resources_limits_memory=${props.resources_limits_memory}
# API URLs도 export (혹시 사용할 수도 있으니)
export auth_url=${props.auth_url}
export member_url=${props.member_url}
export store_url=${props.store_url}
export menu_url=${props.menu_url}
export sales_url=${props.sales_url}
export content_url=${props.content_url}
export recommend_url=${props.recommend_url}
echo "=== 환경변수 확인 ==="
echo "namespace: \$namespace"
echo "ingress_host: \$ingress_host"
echo "export_port: \$export_port"
echo "========================="
envsubst < deployment/${manifest}.template > deployment/${manifest}
echo "Generated manifest file:"
cat deployment/${manifest}
"""
}
container('azure-cli') {
sh """
kubectl apply -f deployment/${manifest}
echo "Waiting for deployment to be ready..."
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing-frontend --timeout=300s
echo "Deployment completed successfully!"
kubectl -n ${namespace} get pods -l app=smarketing-frontend
kubectl -n ${namespace} get svc smarketing-frontend-service
kubectl -n ${namespace} get ingress
"""
}
}
}
}

View File

@ -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 파이프라인 중 오류가 발생했습니다."
}
}
}

View 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;"]

View 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;
}
}

View 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

View 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

View 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

View 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)');

View 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

View File

@ -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>

View File

@ -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"
}
}

View File

@ -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>

View File

@ -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)`);

View File

@ -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
}
}

View File

@ -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)
}
})