From 143f93b9a08c28ae78e20caa429fdcc8e5c5980c Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 10:47:15 +0900 Subject: [PATCH] fix: git merge error fix --- .../service/MarketingTipService.java | 188 +------ .../external/PythonAiTipGenerator.java | 143 ++++++ .../src/main/resources/application.yml | 3 + smarketing-java/build.gradle | 3 + smarketing-java/deployment/Jenkinsfile | 224 ++++++++ .../deployment/container/Dockerfile | 44 ++ .../deployment/deploy.yaml.template | 479 ++++++++++++++++++ smarketing-java/deployment/deploy_env_vars | 23 + smarketing-java/member/Jenkinsfile | 81 +++ .../store/dto/MenuUpdateRequest.java | 3 + 10 files changed, 1014 insertions(+), 177 deletions(-) create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java create mode 100644 smarketing-java/deployment/Jenkinsfile create mode 100644 smarketing-java/deployment/container/Dockerfile create mode 100644 smarketing-java/deployment/deploy.yaml.template create mode 100644 smarketing-java/deployment/deploy_env_vars create mode 100644 smarketing-java/member/Jenkinsfile diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index e6654cf..49b2801 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -103,14 +103,10 @@ public class MarketingTipService implements MarketingTipUseCase { String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); - String tipSummary = generateTipSummary(aiGeneratedTip); - log.info("tipSummary : {}", tipSummary); - // 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(storeWithMenuData.getStoreData().getStoreId()) .tipContent(aiGeneratedTip) - .tipSummary(tipSummary) .storeWithMenuData(storeWithMenuData) .createdAt(LocalDateTime.now()) .build(); @@ -126,10 +122,11 @@ public class MarketingTipService implements MarketingTipUseCase { * 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함) */ private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) { + String tipSummary = generateTipSummary(marketingTip.getTipContent()); return MarketingTipResponse.builder() .tipId(marketingTip.getId().getValue()) - .tipSummary(marketingTip.getTipSummary()) + .tipSummary(tipSummary) .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함 .storeInfo(MarketingTipResponse.StoreInfo.builder() .storeName(storeData.getStoreName()) @@ -142,187 +139,24 @@ public class MarketingTipService implements MarketingTipUseCase { .build(); } + /** + * 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장) + */ private String generateTipSummary(String fullContent) { if (fullContent == null || fullContent.trim().isEmpty()) { return "마케팅 팁이 생성되었습니다."; } - try { - // JSON 형식 처리: "```html\n..." 패턴 - String processedContent = preprocessContent(fullContent); - - // 1순위: HTML 블록 밖의 첫 번째 제목 추출 - String titleOutsideHtml = extractTitleOutsideHtml(processedContent); - if (titleOutsideHtml != null && titleOutsideHtml.length() > 5) { - return titleOutsideHtml; - } - - // 2순위: 태그 안의 첫 번째 내용 추출 - String boldContent = extractBoldContent(processedContent); - if (boldContent != null && boldContent.length() > 5) { - return boldContent; - } - - // 3순위: HTML 태그 제거 후 첫 번째 문장 - return extractFirstSentence(processedContent); - - } catch (Exception e) { - log.error("마케팅 팁 요약 생성 중 오류", e); - return "마케팅 팁이 생성되었습니다."; - } - } - - /** - * JSON이나 특수 형식 전처리 - */ - private String preprocessContent(String content) { - // 먼저 JSON 이스케이프 문자 정리 - if (content.contains("\\n")) { - content = content.replaceAll("\\\\n", "\n"); - } - - // JSON 구조에서 실제 HTML 내용만 추출 - if (content.contains("```html")) { - content = content.replaceAll("```html", "") - .replaceAll("```", "") - .replaceAll("\"", ""); - } - - return content.trim(); - } - - /** - * HTML 블록 밖의 첫 번째 제목 라인 추출 - * ```html 이후 첫 번째 줄의 내용만 추출 - */ - private String extractTitleOutsideHtml(String content) { - // 먼저 이스케이프 문자 정리 - String processedContent = content.replaceAll("\\\\n", "\n"); - - // ```html 패턴 찾기 (이스케이프 처리 후) - String[] htmlPatterns = {"```html\n", "```html\\n"}; - - for (String pattern : htmlPatterns) { - int htmlStart = processedContent.indexOf(pattern); - if (htmlStart != -1) { - // 패턴 이후부터 시작 - int contentStart = htmlStart + pattern.length(); - - // 첫 번째 줄바꿈까지 또는 \n\n까지 찾기 - String remaining = processedContent.substring(contentStart); - String[] lines = remaining.split("\n"); - - if (lines.length > 0) { - String firstLine = lines[0].trim(); - - // 유효한 내용인지 확인 - if (firstLine.length() > 5 && !firstLine.contains("🎯") && !firstLine.contains("<")) { - return cleanText(firstLine); - } - } - } - } - - // 기존 방식으로 fallback - return extractFromLines(processedContent); - } - - /** - * 줄별로 처리하는 기존 방식 - */ - private String extractFromLines(String content) { - String[] lines = content.split("\n"); - - for (String line : lines) { - line = line.trim(); - - // 빈 줄이나 HTML 태그, 이모지로 시작하는 줄 건너뛰기 - if (line.isEmpty() || - line.contains("<") || - line.startsWith("🎯") || - line.startsWith("🔍") || - line.equals("```html") || - line.matches("^[\\p{So}\\p{Sk}\\s]+$")) { - continue; - } - - // 의미있는 제목 라인 발견 - if (line.length() > 5) { - return cleanText(line); - } - } - - return null; - } - - /** - * 태그 안의 첫 번째 내용 추출 - */ - private String extractBoldContent(String htmlContent) { - int startIndex = htmlContent.indexOf(""); - if (startIndex == -1) { - return null; - } - - int endIndex = htmlContent.indexOf("", startIndex); - if (endIndex == -1) { - return null; - } - - String content = htmlContent.substring(startIndex + 3, endIndex).trim(); - return cleanText(content); - } - - /** - * 텍스트 정리 - */ - private String cleanText(String text) { - if (text == null) { - return null; - } - - return text.replaceAll(" ", " ") - .replaceAll("\\s+", " ") - .trim(); - } - - /** - * HTML 태그 제거 후 첫 번째 의미있는 문장 추출 - */ - private String extractFirstSentence(String htmlContent) { - // HTML 태그 모두 제거 - String cleanContent = htmlContent.replaceAll("<[^>]+>", "").trim(); - - // 줄별로 나누어서 첫 번째 의미있는 줄 찾기 - String[] lines = cleanContent.split("\\n"); - - for (String line : lines) { - line = line.trim(); - - // 빈 줄이나 이모지만 있는 줄 건너뛰기 - if (line.isEmpty() || line.matches("^[\\p{So}\\p{Sk}\\s]+$")) { - continue; - } - - // 최소 길이 체크하고 반환 - if (line.length() > 5) { - // 50자 제한 - if (line.length() > 50) { - return line.substring(0, 50).trim() + "..."; - } - return line; - } - } - - // 모든 방법이 실패하면 기존 방식 사용 - String[] sentences = cleanContent.split("[.!?]"); - String firstSentence = sentences.length > 0 ? sentences[0].trim() : cleanContent; + // 첫 번째 문장으로 요약 (마침표 기준) + String[] sentences = fullContent.split("[.!?]"); + String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent; + // 50자 제한 if (firstSentence.length() > 50) { - firstSentence = firstSentence.substring(0, 50).trim() + "..."; + return firstSentence.substring(0, 47) + "..."; } - return firstSentence.isEmpty() ? "마케팅 팁이 생성되었습니다." : firstSentence; + return firstSentence; } /** diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java new file mode 100644 index 0000000..e091fc5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -0,0 +1,143 @@ +package com.won.smarketing.recommend.infrastructure.external; + +import com.won.smarketing.recommend.domain.model.MenuData; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; // 이 어노테이션이 누락되어 있었음 +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Python AI 팁 생성 구현체 (날씨 정보 제거) + */ +@Slf4j +@Service // 추가된 어노테이션 +@RequiredArgsConstructor +public class PythonAiTipGenerator implements AiTipGenerator { + + private final WebClient webClient; + + @Value("${external.python-ai-service.base-url}") + private String pythonAiServiceBaseUrl; + + @Value("${external.python-ai-service.api-key}") + private String pythonAiServiceApiKey; + + @Value("${external.python-ai-service.timeout}") + private int timeout; + + @Override + public String generateTip(StoreWithMenuData storeWithMenuData) { + try { + log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName()); + return callPythonAiService(storeWithMenuData); + + } catch (Exception e) { + log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); + return createFallbackTip(storeWithMenuData); + } + } + + private String callPythonAiService(StoreWithMenuData storeWithMenuData) { + + try { + + StoreData storeData = storeWithMenuData.getStoreData(); + List menuDataList = storeWithMenuData.getMenuDataList(); + + // 메뉴 데이터를 Map 형태로 변환 + List> menuList = menuDataList.stream() + .map(menu -> { + Map menuMap = new HashMap<>(); + menuMap.put("menu_id", menu.getMenuId()); + menuMap.put("menu_name", menu.getMenuName()); + menuMap.put("category", menu.getCategory()); + menuMap.put("price", menu.getPrice()); + menuMap.put("description", menu.getDescription()); + return menuMap; + }) + .collect(Collectors.toList()); + + // Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보) + Map requestData = new HashMap<>(); + requestData.put("store_name", storeData.getStoreName()); + requestData.put("business_type", storeData.getBusinessType()); + requestData.put("location", storeData.getLocation()); + requestData.put("seat_count", storeData.getSeatCount()); + requestData.put("menu_list", menuList); + + log.debug("Python AI 서비스 요청 데이터: {}", requestData); + + PythonAiResponse response = webClient + .post() + .uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip") + .header("Authorization", "Bearer " + pythonAiServiceApiKey) + .header("Content-Type", "application/json") + .bodyValue(requestData) + .retrieve() + .bodyToMono(PythonAiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) { + log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length()); + return response.getTip(); + } + } catch (Exception e) { + log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage()); + } + + return createFallbackTip(storeWithMenuData); + } + + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreWithMenuData storeWithMenuData) { + String businessType = storeWithMenuData.getStoreData().getBusinessType(); + String storeName = storeWithMenuData.getStoreData().getStoreName(); + String location = storeWithMenuData.getStoreData().getLocation(); + + // 업종별 기본 팁 생성 + if (businessType.contains("카페")) { + return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName); + } else if (businessType.contains("음식점") || businessType.contains("식당")) { + return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName); + } else if (businessType.contains("베이커리") || businessType.contains("빵집")) { + return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName); + } else if (businessType.contains("치킨") || businessType.contains("튀김")) { + return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName); + } + + // 지역별 팁 + if (location.contains("강남") || location.contains("서초")) { + return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName); + } else if (location.contains("홍대") || location.contains("신촌")) { + return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName); + } + + // 기본 팁 + return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName); + } + + @Getter + private static class PythonAiResponse { + private String tip; + private String status; + private String message; + private LocalDateTime generatedTip; + private String businessType; + private String aiModel; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index ee94915..985f4a3 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -33,12 +33,15 @@ external: api-key: ${PYTHON_AI_API_KEY:dummy-key} timeout: ${PYTHON_AI_TIMEOUT:30000} +<<<<<<< HEAD azure: eventhub: namespace: ${AZURE_EVENTHUB_NAMESPACE} marketing-tip-hub: ${AZURE_EVENTHUB_MARKETING_TIP_HUB:marketing-tip-requests} consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:ai-recommend-service} +======= +>>>>>>> origin/main management: endpoints: web: diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index 30d5b26..fefa680 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -47,10 +47,13 @@ subprojects { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' +<<<<<<< HEAD implementation 'com.azure:azure-messaging-eventhubs:5.18.0' implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-identity:1.11.4' +======= +>>>>>>> origin/main } tasks.named('test') { diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile new file mode 100644 index 0000000..f9a348d --- /dev/null +++ b/smarketing-java/deployment/Jenkinsfile @@ -0,0 +1,224 @@ +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: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'), + containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, 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: '/home/gradle/.gradle', memory: false), + emptyDirVolume(mountPath: '/root/.azure', memory: false), + emptyDirVolume(mountPath: '/var/run', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + def manifest = "deploy.yaml" + def namespace + def services = ['member', 'store', 'marketing-content', 'ai-recommend'] + + stage("Get Source") { + checkout scm + + // smarketing-java 하위에 있는 설정 파일 읽기 + props = readProperties file: "smarketing-java/deployment/deploy_env_vars" + namespace = "${props.namespace}" + + echo "=== Build Information ===" + echo "Services: ${services}" + echo "Namespace: ${namespace}" + echo "Image Tag: ${imageTag}" + } + + stage("Setup AKS") { + container('azure-cli') { + withCredentials([azureServicePrincipal('azure-credentials')]) { + sh """ + echo "=== Azure 로그인 ===" + az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID + az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66 + + echo "=== AKS 인증정보 가져오기 (rg-digitalgarage-02) ===" + az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing + + echo "=== 네임스페이스 생성 ===" + kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - + + echo "=== Image Pull Secret 생성 ===" + kubectl create secret docker-registry acr-secret \\ + --docker-server=${props.registry} \\ + --docker-username=acrdigitalgarage02 \\ + --docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\ + --namespace=${namespace} \\ + --dry-run=client -o yaml | kubectl apply -f - + + echo "=== 클러스터 상태 확인 ===" + kubectl get nodes + kubectl get ns ${namespace} + + echo "=== 현재 연결된 클러스터 확인 ===" + kubectl config current-context + """ + } + } + } + + stage('Build Applications') { + container('gradle') { + sh """ + echo "=== smarketing-java 디렉토리로 이동 ===" + cd smarketing-java + + echo "=== gradlew 권한 설정 ===" + chmod +x gradlew + + echo "=== 전체 서비스 빌드 ===" + ./gradlew :member:clean :member:build -x test + ./gradlew :store:clean :store:build -x test + ./gradlew :marketing-content:clean :marketing-content:build -x test + ./gradlew :ai-recommend:clean :ai-recommend:build -x test + + echo "=== 빌드 결과 확인 ===" + find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar' + """ + } + } + + stage('Build & Push Images') { + container('docker') { + sh """ + echo "=== Docker 데몬 시작 대기 ===" + timeout 30 sh -c 'until docker info; do sleep 1; done' + """ + + // ACR Credential을 Jenkins에서 직접 사용 + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "=== Docker로 ACR 로그인 ===" + echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin + """ + + services.each { service -> + script { + def buildDir = "smarketing-java/${service}" + def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}" + + echo "Building image for ${service}: ${fullImageName}" + + // 실제 JAR 파일명 동적 탐지 + def actualJarFile = sh( + script: """ + cd ${buildDir}/build/libs + ls *.jar | grep -v 'plain.jar' | head -1 + """, + returnStdout: true + ).trim() + + if (!actualJarFile) { + error "${service} JAR 파일을 찾을 수 없습니다" + } + + echo "발견된 JAR 파일: ${actualJarFile}" + + sh """ + echo "=== ${service} 이미지 빌드 ===" + docker build \\ + --build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\ + --build-arg ARTIFACTORY_FILE="${actualJarFile}" \\ + -f smarketing-java/deployment/container/Dockerfile \\ + -t ${fullImageName} . + + echo "=== ${service} 이미지 푸시 ===" + docker push ${fullImageName} + + echo "Successfully built and pushed: ${fullImageName}" + """ + } + } + } + } + } + + stage('Generate & Apply Manifest') { + container('envsubst') { + sh """ + echo "=== 환경변수 설정 ===" + export namespace=${namespace} + export allowed_origins=${props.allowed_origins} + export jwt_secret_key=${props.jwt_secret_key} + export postgres_user=${props.postgres_user} + export postgres_password=${props.postgres_password} + export replicas=${props.replicas} + # 리소스 요구사항 조정 (작게) + export resources_requests_cpu=100m + export resources_requests_memory=128Mi + export resources_limits_cpu=500m + export resources_limits_memory=512Mi + + # 이미지 경로 환경변수 설정 + export member_image_path=${props.registry}/${props.image_org}/member:${imageTag} + export store_image_path=${props.registry}/${props.image_org}/store:${imageTag} + export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag} + export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag} + + echo "=== Manifest 생성 ===" + envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest} + + echo "=== Generated Manifest File ===" + cat smarketing-java/deployment/${manifest} + echo "===============================" + """ + } + + container('azure-cli') { + sh """ + echo "=== 현재 연결된 클러스터 재확인 ===" + kubectl config current-context + kubectl cluster-info | head -3 + + echo "=== PostgreSQL 서비스 확인 ===" + kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요." + + echo "=== Manifest 적용 ===" + kubectl apply -f smarketing-java/deployment/${manifest} + + echo "=== 배포 상태 확인 (60초 대기) ===" + kubectl -n ${namespace} get deployments + kubectl -n ${namespace} get pods + + echo "=== 각 서비스 배포 대기 (60초 timeout) ===" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃" + + echo "=== 최종 상태 ===" + kubectl -n ${namespace} get all + + echo "=== 실패한 Pod 상세 정보 ===" + for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do + if [ ! -z "\$pod" ]; then + echo "=== 실패한 Pod: \$pod ===" + kubectl -n ${namespace} describe \$pod | tail -20 + fi + done + """ + } + } + } +} diff --git a/smarketing-java/deployment/container/Dockerfile b/smarketing-java/deployment/container/Dockerfile new file mode 100644 index 0000000..be0f578 --- /dev/null +++ b/smarketing-java/deployment/container/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM eclipse-temurin:17-jre AS builder +ARG BUILD_LIB_DIR +ARG ARTIFACTORY_FILE +WORKDIR /app +COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar + +# Run stage +FROM eclipse-temurin:17-jre + +# Install necessary packages +RUN apt-get update && apt-get install -y \ + curl \ + netcat-traditional \ + && rm -rf /var/lib/apt/lists/* + +ENV USERNAME k8s +ENV ARTIFACTORY_HOME /home/${USERNAME} +ENV JAVA_OPTS="" + +# Add a non-root user +RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \ + mkdir -p ${ARTIFACTORY_HOME} && \ + chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME} + +WORKDIR ${ARTIFACTORY_HOME} + +# Copy JAR from builder stage +COPY --from=builder /app/app.jar app.jar +RUN chown ${USERNAME}:${USERNAME} app.jar + +# Switch to non-root user +USER ${USERNAME} + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run the application +ENTRYPOINT ["sh", "-c"] +CMD ["java ${JAVA_OPTS} -jar app.jar"] diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template new file mode 100644 index 0000000..d14f15e --- /dev/null +++ b/smarketing-java/deployment/deploy.yaml.template @@ -0,0 +1,479 @@ +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: common-config + namespace: ${namespace} +data: + ALLOWED_ORIGINS: ${allowed_origins} + JPA_DDL_AUTO: update + JPA_SHOW_SQL: 'true' + # 🔧 Actuator 보안 설정 추가 + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info + MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always + MANAGEMENT_SECURITY_ENABLED: 'false' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: member-config + namespace: ${namespace} +data: + POSTGRES_DB: member + POSTGRES_HOST: member-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8081' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: store-config + namespace: ${namespace} +data: + POSTGRES_DB: store + POSTGRES_HOST: store-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8082' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: marketing-content-config + namespace: ${namespace} +data: + POSTGRES_DB: marketing_content + POSTGRES_HOST: marketing-content-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8083' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ai-recommend-config + namespace: ${namespace} +data: + POSTGRES_DB: ai_recommend + POSTGRES_HOST: ai-recommend-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8084' + +--- +# Secrets +apiVersion: v1 +kind: Secret +metadata: + name: common-secret + namespace: ${namespace} +stringData: + JWT_SECRET_KEY: ${jwt_secret_key} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: member-secret + namespace: ${namespace} +stringData: + JWT_ACCESS_TOKEN_VALIDITY: '3600000' + JWT_REFRESH_TOKEN_VALIDITY: '86400000' + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: store-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: marketing-content-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: ai-recommend-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +# Deployments +apiVersion: apps/v1 +kind: Deployment +metadata: + name: member + namespace: ${namespace} + labels: + app: member +spec: + replicas: ${replicas} + selector: + matchLabels: + app: member + template: + metadata: + labels: + app: member + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: member + image: ${member_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8081 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: member-config + - secretRef: + name: common-secret + - secretRef: + name: member-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z member-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: store + namespace: ${namespace} + labels: + app: store +spec: + replicas: ${replicas} + selector: + matchLabels: + app: store + template: + metadata: + labels: + app: store + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: store + image: ${store_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8082 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: store-config + - secretRef: + name: common-secret + - secretRef: + name: store-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z store-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: marketing-content + namespace: ${namespace} + labels: + app: marketing-content +spec: + replicas: ${replicas} + selector: + matchLabels: + app: marketing-content + template: + metadata: + labels: + app: marketing-content + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: marketing-content + image: ${marketing_content_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8083 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: marketing-content-config + - secretRef: + name: common-secret + - secretRef: + name: marketing-content-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z marketing-content-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ai-recommend + namespace: ${namespace} + labels: + app: ai-recommend +spec: + replicas: ${replicas} + selector: + matchLabels: + app: ai-recommend + template: + metadata: + labels: + app: ai-recommend + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: ai-recommend + image: ${ai_recommend_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8084 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: ai-recommend-config + - secretRef: + name: common-secret + - secretRef: + name: ai-recommend-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z ai-recommend-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +# Services +apiVersion: v1 +kind: Service +metadata: + name: member + namespace: ${namespace} +spec: + selector: + app: member + ports: + - port: 80 + targetPort: 8081 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: store + namespace: ${namespace} +spec: + selector: + app: store + ports: + - port: 80 + targetPort: 8082 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: marketing-content + namespace: ${namespace} +spec: + selector: + app: marketing-content + ports: + - port: 80 + targetPort: 8083 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: ai-recommend + namespace: ${namespace} +spec: + selector: + app: ai-recommend + ports: + - port: 80 + targetPort: 8084 + type: ClusterIP + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: smarketing-backend + namespace: ${namespace} + annotations: + kubernetes.io/ingress.class: nginx +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: /api/auth + pathType: Prefix + backend: + service: + name: member + port: + number: 80 + - path: /api/store + pathType: Prefix + backend: + service: + name: store + port: + number: 80 + - path: /api/content + pathType: Prefix + backend: + service: + name: marketing-content + port: + number: 80 + - path: /api/recommend + pathType: Prefix + backend: + service: + name: ai-recommend + port: + number: 80 diff --git a/smarketing-java/deployment/deploy_env_vars b/smarketing-java/deployment/deploy_env_vars new file mode 100644 index 0000000..db95eda --- /dev/null +++ b/smarketing-java/deployment/deploy_env_vars @@ -0,0 +1,23 @@ +# Team Settings +teamid=kros235 +root_project=smarketing-backend +namespace=smarketing + +# Container Registry Settings +registry=acrdigitalgarage02.azurecr.io +image_org=smarketing + +# Application Settings +replicas=1 +allowed_origins=http://20.249.171.38 + +# Security Settings +jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ +postgres_user=admin +postgres_password=Hi5Jessica! + +# Resource Settings (리소스 요구사항 줄임) +resources_requests_cpu=100m +resources_requests_memory=128Mi +resources_limits_cpu=500m +resources_limits_memory=512Mi diff --git a/smarketing-java/member/Jenkinsfile b/smarketing-java/member/Jenkinsfile new file mode 100644 index 0000000..3267d52 --- /dev/null +++ b/smarketing-java/member/Jenkinsfile @@ -0,0 +1,81 @@ +pipeline { + agent any + + environment { + ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io' + IMAGE_NAME = 'member' + MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git' + MANIFEST_PATH = 'member/deployment.yaml' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build') { + steps { + dir('member') { + sh './gradlew clean build -x test' + } + } + } + + stage('Test') { + steps { + dir('member') { + sh './gradlew test' + } + } + } + + stage('Build Docker Image') { + steps { + script { + def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}" + def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}" + + dir('member') { + sh "docker build -t ${fullImageName} ." + } + + withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) { + sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}" + sh "docker push ${fullImageName}" + } + + env.IMAGE_TAG = imageTag + env.FULL_IMAGE_NAME = fullImageName + } + } + } + + stage('Update Manifest') { + steps { + withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) { + sh ''' + git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo + cd manifest-repo + + # Update image tag in deployment.yaml + sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH} + + git config user.email "jenkins@smarketing.com" + git config user.name "Jenkins" + git add . + git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}" + git push origin main + ''' + } + } + } + } + + post { + always { + cleanWs() + } + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index da0360a..c5ed581 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -35,8 +35,11 @@ public class MenuUpdateRequest { @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") private String description; +<<<<<<< HEAD @Schema(description = "이미지") @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") private MultipartFile image; +======= +>>>>>>> origin/main }