Merge branch 'main' into marketing-contents

This commit is contained in:
박서은 2025-06-17 15:09:31 +09:00
commit 71cd2ef9cf
22 changed files with 987 additions and 285 deletions

View File

@ -2,11 +2,12 @@ FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
# 경로 수정
COPY smarketing-ai/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
COPY smarketing-ai/ .
# 포트 노출
EXPOSE 5001

176
smarketing-ai/deployment/Jenkinsfile vendored Normal file
View File

@ -0,0 +1,176 @@
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: '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: '/run/podman', memory: false),
emptyDirVolume(mountPath: '/root/.azure', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
def manifest = "deploy.yaml"
def namespace
stage("Get Source") {
checkout scm
props = readProperties file: "smarketing-ai/deployment/deploy_env_vars"
namespace = "${props.namespace}"
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
echo "Team ID: ${props.teamid}"
}
stage("Check Changes") {
script {
def changes = sh(
script: "git diff --name-only HEAD~1 HEAD",
returnStdout: true
).trim()
echo "Changed files: ${changes}"
if (!changes.contains("smarketing-ai/")) {
echo "No changes in smarketing-ai, skipping build"
currentBuild.result = 'SUCCESS'
error("Stopping pipeline - no changes detected")
}
echo "Changes detected in smarketing-ai, proceeding with build"
}
}
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 Docker 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'
)]) {
sh """
echo "=========================================="
echo "Building smarketing-ai Python Flask application"
echo "Image Tag: ${imageTag}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \
-f smarketing-ai/deployment/Dockerfile \
-t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} .
# 이미지 푸시
podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
"""
}
}
}
stage('Generate & Apply Manifest') {
container('envsubst') {
withCredentials([
string(credentialsId: 'SECRET_KEY', variable: 'SECRET_KEY'),
string(credentialsId: 'CLAUDE_API_KEY', variable: 'CLAUDE_API_KEY'),
string(credentialsId: 'OPENAI_API_KEY', variable: 'OPENAI_API_KEY'),
string(credentialsId: 'AZURE_STORAGE_ACCOUNT_NAME', variable: 'AZURE_STORAGE_ACCOUNT_NAME'),
string(credentialsId: 'AZURE_STORAGE_ACCOUNT_KEY', variable: 'AZURE_STORAGE_ACCOUNT_KEY')
]) {
sh """
export namespace=${namespace}
export replicas=${props.replicas}
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}
export upload_folder=${props.upload_folder}
export max_content_length=${props.max_content_length}
export allowed_extensions=${props.allowed_extensions}
export server_host=${props.server_host}
export server_port=${props.server_port}
export azure_storage_container_name=${props.azure_storage_container_name}
# 이미지 경로 환경변수 설정
export smarketing_image_path=${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
# Sensitive 환경변수 설정 (Jenkins Credentials에서)
export secret_key=\$SECRET_KEY
export claude_api_key=\$CLAUDE_API_KEY
export openai_api_key=\$OPENAI_API_KEY
export azure_storage_account_name=\$AZURE_STORAGE_ACCOUNT_NAME
export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY
# manifest 생성
envsubst < smarketing-ai/deployment/${manifest}.template > smarketing-ai/deployment/${manifest}
echo "Generated manifest file:"
cat smarketing-ai/deployment/${manifest}
"""
}
}
container('azure-cli') {
sh """
kubectl apply -f smarketing-ai/deployment/${manifest}
echo "Waiting for smarketing deployment to be ready..."
kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s
echo "=========================================="
echo "Getting LoadBalancer External IP..."
# External IP 확인 (최대 5분 대기)
for i in {1..30}; do
EXTERNAL_IP=\$(kubectl -n ${namespace} get service smarketing-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
if [ "\$EXTERNAL_IP" != "" ] && [ "\$EXTERNAL_IP" != "null" ]; then
echo "External IP assigned: \$EXTERNAL_IP"
break
fi
echo "Waiting for External IP... (attempt \$i/30)"
sleep 10
done
# 서비스 상태 확인
kubectl -n ${namespace} get pods -l app=smarketing
kubectl -n ${namespace} get service smarketing-service
echo "=========================================="
echo "Deployment Complete!"
echo "Service URL: http://\$EXTERNAL_IP:${props.server_port}"
echo "Health Check: http://\$EXTERNAL_IP:${props.server_port}/health"
echo "=========================================="
"""
}
}
}
}

View File

@ -0,0 +1,170 @@
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: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true),
containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
],
volumes: [
emptyDirVolume(mountPath: '/run/podman', memory: false)
]
) {
node(PIPELINE_ID) {
def props
def imageTag = getImageTag()
stage("Get Source") {
checkout scm
props = readProperties file: "deployment/deploy_env_vars"
}
stage('Build & Push Docker 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'
)]) {
sh """
echo "=========================================="
echo "Building smarketing-ai for ArgoCD GitOps"
echo "Image Tag: ${imageTag}"
echo "=========================================="
# ACR 로그인
echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin
# Docker 이미지 빌드
podman build \
-f deployment/container/Dockerfile \
-t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} .
# 이미지 푸시
podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
"""
}
}
}
stage('Update Manifest Repository') {
container('git') {
withCredentials([usernamePassword(
credentialsId: 'github-credentials-${props.teamid}',
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_PASSWORD'
)]) {
sh """
# Git 설정
git config --global user.email "jenkins@company.com"
git config --global user.name "Jenkins CI"
# Manifest 저장소 클론 (팀별 저장소로 수정 필요)
git clone https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/your-team/smarketing-ai-manifest.git
cd smarketing-ai-manifest
echo "=========================================="
echo "Updating smarketing-ai manifest repository:"
echo "New Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}"
# smarketing deployment 파일 업데이트
if [ -f "smarketing/smarketing-deployment.yaml" ]; then
# 이미지 태그 업데이트
sed -i "s|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}|g" \
smarketing/smarketing-deployment.yaml
echo "Updated smarketing deployment to image tag: ${imageTag}"
cat smarketing/smarketing-deployment.yaml | grep "image:"
else
echo "Warning: smarketing-deployment.yaml not found"
echo "Creating manifest directory structure..."
# 기본 구조 생성
mkdir -p smarketing
# 기본 deployment 파일 생성
cat > smarketing/smarketing-deployment.yaml << EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing
namespace: smarketing
labels:
app: smarketing
spec:
replicas: 1
selector:
matchLabels:
app: smarketing
template:
metadata:
labels:
app: smarketing
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing
image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
imagePullPolicy: Always
ports:
- containerPort: 5001
resources:
requests:
cpu: 256m
memory: 512Mi
limits:
cpu: 1024m
memory: 2048Mi
envFrom:
- configMapRef:
name: smarketing-config
- secretRef:
name: smarketing-secret
volumeMounts:
- name: upload-storage
mountPath: /app/uploads
- name: temp-storage
mountPath: /app/uploads/temp
volumes:
- name: upload-storage
emptyDir: {}
- name: temp-storage
emptyDir: {}
EOF
echo "Created basic smarketing-deployment.yaml"
fi
# 변경사항 커밋 및 푸시
git add .
git commit -m "Update smarketing-ai image tag to ${imageTag}
Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}
Build: ${env.BUILD_NUMBER}
Branch: ${env.BRANCH_NAME}
Commit: ${env.GIT_COMMIT}"
git push origin main
echo "=========================================="
echo "ArgoCD GitOps Update Completed!"
echo "Updated Service: smarketing-ai:${imageTag}"
echo "ArgoCD will automatically detect and deploy these changes."
echo "=========================================="
"""
}
}
}
}
}

View File

@ -0,0 +1,113 @@
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: smarketing-config
namespace: ${namespace}
data:
SERVER_HOST: "${server_host}"
SERVER_PORT: "${server_port}"
UPLOAD_FOLDER: "${upload_folder}"
MAX_CONTENT_LENGTH: "${max_content_length}"
ALLOWED_EXTENSIONS: "${allowed_extensions}"
AZURE_STORAGE_CONTAINER_NAME: "${azure_storage_container_name}"
---
# Secret
apiVersion: v1
kind: Secret
metadata:
name: smarketing-secret
namespace: ${namespace}
type: Opaque
stringData:
SECRET_KEY: "${secret_key}"
CLAUDE_API_KEY: "${claude_api_key}"
OPENAI_API_KEY: "${openai_api_key}"
AZURE_STORAGE_ACCOUNT_NAME: "${azure_storage_account_name}"
AZURE_STORAGE_ACCOUNT_KEY: "${azure_storage_account_key}"
---
# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: smarketing
namespace: ${namespace}
labels:
app: smarketing
spec:
replicas: ${replicas}
selector:
matchLabels:
app: smarketing
template:
metadata:
labels:
app: smarketing
spec:
imagePullSecrets:
- name: acr-secret
containers:
- name: smarketing
image: ${smarketing_image_path}
imagePullPolicy: Always
ports:
- containerPort: 5001
resources:
requests:
cpu: ${resources_requests_cpu}
memory: ${resources_requests_memory}
limits:
cpu: ${resources_limits_cpu}
memory: ${resources_limits_memory}
envFrom:
- configMapRef:
name: smarketing-config
- secretRef:
name: smarketing-secret
volumeMounts:
- name: upload-storage
mountPath: /app/uploads
- name: temp-storage
mountPath: /app/uploads/temp
livenessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: upload-storage
emptyDir: {}
- name: temp-storage
emptyDir: {}
---
# Service (LoadBalancer type for External IP)
apiVersion: v1
kind: Service
metadata:
name: smarketing-service
namespace: ${namespace}
labels:
app: smarketing
spec:
type: LoadBalancer
ports:
- port: 5001
targetPort: 5001
protocol: TCP
name: http
selector:
app: smarketing

View File

@ -0,0 +1,27 @@
# Team Settings
teamid=won
root_project=smarketing-ai
namespace=smarketing
# Container Registry Settings
registry=acrdigitalgarage02.azurecr.io
image_org=won
# Application Settings
replicas=1
# Resource Settings
resources_requests_cpu=256m
resources_requests_memory=512Mi
resources_limits_cpu=1024m
resources_limits_memory=2048Mi
# Flask App Settings (non-sensitive)
upload_folder=/app/uploads
max_content_length=16777216
allowed_extensions=png,jpg,jpeg,gif,webp
server_host=0.0.0.0
server_port=5001
# Azure Storage Settings (non-sensitive)
azure_storage_container_name=ai-content

View File

@ -5,6 +5,10 @@ metadata:
namespace: smarketing
type: Opaque
stringData:
SECRET_KEY: "your-secret-key-change-in-production"
CLAUDE_API_KEY: "your-claude-api-key"
OPENAI_API_KEY: "your-openai-api-key"
SECRET_KEY:
CLAUDE_API_KEY:
OPENAI_API_KEY:
AZURE_STORAGE_ACCOUNT_NAME: "stdigitalgarage02"
AZURE_STORAGE_ACCOUNT_KEY:
AZURE_STORAGE_CONTAINER_NAME: "ai-content"

View File

@ -101,7 +101,8 @@ class MarketingTipService:
당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다.
지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요.
소상공인을 위한 실용적인 마케팅 팁을 생성해주세요.
소상공인을 위한 현실적이고 바로 실행할 있는 실용적인 마케팅 팁을 생성해주세요.
협업보다는 할인, 포스팅 당장 실현 가능한 현실적이면서도 창의적인 방법을 추천해주세요.
매장 정보:
- 매장명: {store_name}
@ -123,17 +124,13 @@ class MarketingTipService:
prompt += """
아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요:
1. **실행 가능성**: 소상공인이 실제로 적용할 있는 현실적인 방법
1. **실행 가능성**: 소상공인이 실제로 바로 적용할 있는 현실적인 방법
2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 있는 전략
3. **구체성**: 실행 단계가 명확하고 구체적일
4. **시의성**: 현재 계절, 유행, 트렌드를 반영
5. **지역성**: 지역 특성 현재 날씨를 고려할
응답 형식 (300 내외, 간결하게):
html 형식으로 출력
핵심 마케팅 팁은 제목없이 한번 상단에 보여주세요
부제목과 내용은 분리해서 출력
아래의 부제목 앞에는 이모지 포함
출력해야할 내용:
- 핵심 마케팅 (1)
- 실행 방법 (1)
- 예상 비용과 기대 효과
@ -141,6 +138,27 @@ html 형식으로 출력
- 참고했던 실제 성공한 마케팅
- 오늘의 응원의 문장 (간결하게 1)
아래 HTML 템플릿 형식으로 응답해주세요. <h3> 태그는 절대 변경하지 말고, <p> 태그 내용만 새로 작성해주세요
<p> 태그 내용 외에 다른 내용은 절대 넣지 마세요 :
<h3> 핵심 마케팅 </h3>
<p>[여기에 새로운 핵심 마케팅 작성]</p>
<h3>🚀 실행 방법</h3>
<p>[여기에 새로운 실행 방법 내용 작성]</p>
<h3>💰 예상 비용과 기대 효과</h3>
<p>[여기에 새로운 비용/효과 내용 작성]</p>
<h3> 주의사항</h3>
<p>[여기에 새로운 주의사항 내용 작성]</p>
<h3>📈 참고했던 실제 성공한 마케팅</h3>
<p>[여기에 새로운 참고 사례 내용 작성, 존재하지 않는 사례는 절대 참고하지 말고, 실제 존재하는 마케팅 성공 사례로만 작성. 참고했던 존재하는 url로 함께 표기]</p>
<h3>🙌 오늘의 응원의 문장</h3>
<p>[여기에 응원의 문장 작성]</p>
심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요.
"""

View File

@ -1714,8 +1714,7 @@ class SnsContentService:
6. 해시태그는 본문과 자연스럽게 연결되도록 배치
**필수 요구사항:**
{request.requirement #or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'
}
{request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'
인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요.
필수 요구사항을 반드시 참고하여 작성해주세요.
@ -1788,9 +1787,7 @@ class SnsContentService:
- 이미지 태그 다음 줄에 이미지 설명 문구 작성
**필수 요구사항:**
{request.requirement
# or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'
}
{request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'
네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요.
필수 요구사항을 반드시 참고하여 작성해주세요.

View File

@ -104,13 +104,12 @@ public class MarketingTipService implements MarketingTipUseCase {
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)
.tipContent(aiGeneratedTip)
.storeWithMenuData(storeWithMenuData)
.createdAt(LocalDateTime.now())
.build();
@ -142,113 +141,80 @@ public class MarketingTipService implements MarketingTipUseCase {
.build();
}
/**
* 마케팅 요약 생성 (핵심 마케팅 섹션에서 번째 문장 추출)
*
* @param fullContent AI로 생성된 전체 마케팅 HTML 콘텐츠
* @return 핵심 마케팅 팁의 번째 문장
*/
private String generateTipSummary(String fullContent) {
if (fullContent == null || fullContent.trim().isEmpty()) {
return "마케팅 팁이 생성되었습니다.";
}
try {
// JSON 형식 처리: "```html\n..." 패턴
String processedContent = preprocessContent(fullContent);
// 1. "✨ 핵심 마케팅 팁" 섹션 추출
String coreSection = extractCoreMarketingTipSection(fullContent);
// 1순위: HTML 블록 밖의 번째 제목 추출
String titleOutsideHtml = extractTitleOutsideHtml(processedContent);
if (titleOutsideHtml != null && titleOutsideHtml.length() > 5) {
return titleOutsideHtml;
if (coreSection != null && !coreSection.trim().isEmpty()) {
// 2. HTML 태그 제거
String cleanText = removeHtmlTags(coreSection);
// 3. 번째 의미있는 문장 추출
String summary = extractFirstMeaningfulSentence(cleanText);
// 4. 길이 제한 (100자 이내)
if (summary.length() > 100) {
summary = summary.substring(0, 97) + "...";
}
return summary;
}
// 2순위: <b> 태그 안의 번째 내용 추출
String boldContent = extractBoldContent(processedContent);
if (boldContent != null && boldContent.length() > 5) {
return boldContent;
}
// 3순위: HTML 태그 제거 번째 문장
return extractFirstSentence(processedContent);
// 핵심 섹션을 찾지 못한 경우 fallback 처리
return extractFallbackSummary(fullContent);
} catch (Exception e) {
log.error("마케팅 팁 요약 생성 중 오류", e);
return "마케팅 팁이 생성되었습니다.";
log.warn("마케팅 팁 요약 생성 중 오류 발생, 기본 메시지 반환: {}", e.getMessage());
return "맞춤형 마케팅 팁이 생성되었습니다.";
}
}
/**
* JSON이나 특수 형식 전처리
* "✨ 핵심 마케팅 팁" 섹션 추출
*/
private String preprocessContent(String content) {
// 먼저 JSON 이스케이프 문자 정리
if (content.contains("\\n")) {
content = content.replaceAll("\\\\n", "\n");
}
private String extractCoreMarketingTipSection(String fullContent) {
// 핵심 마케팅 섹션 시작 패턴들
String[] corePatterns = {
"✨ 핵심 마케팅 팁",
"<h3>✨ 핵심 마케팅 팁</h3>",
"핵심 마케팅 팁"
};
// JSON 구조에서 실제 HTML 내용만 추출
if (content.contains("```html")) {
content = content.replaceAll("```html", "")
.replaceAll("```", "")
.replaceAll("\"", "");
}
// 다음 섹션 시작 패턴들
String[] nextSectionPatterns = {
"🚀 실행 방법",
"<h3>🚀 실행 방법</h3>",
"💰 예상 비용",
"<h3>💰 예상 비용"
};
return content.trim();
}
for (String pattern : corePatterns) {
int startIndex = fullContent.indexOf(pattern);
if (startIndex != -1) {
// 패턴 뒤부터 시작
int contentStart = startIndex + pattern.length();
/**
* 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);
// 다음 섹션까지의 내용 추출
int endIndex = fullContent.length();
for (String nextPattern : nextSectionPatterns) {
int nextIndex = fullContent.indexOf(nextPattern, contentStart);
if (nextIndex != -1 && nextIndex < endIndex) {
endIndex = nextIndex;
}
}
}
}
// 기존 방식으로 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 fullContent.substring(contentStart, endIndex).trim();
}
}
@ -256,73 +222,87 @@ public class MarketingTipService implements MarketingTipUseCase {
}
/**
* <b> 태그 안의 번째 내용 추출
* HTML 태그 제거
*/
private String extractBoldContent(String htmlContent) {
int startIndex = htmlContent.indexOf("<b>");
if (startIndex == -1) {
return null;
}
private String removeHtmlTags(String htmlText) {
if (htmlText == null) return "";
int endIndex = htmlContent.indexOf("</b>", 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("&nbsp;", " ")
.replaceAll("\\s+", " ")
return htmlText
.replaceAll("<[^>]+>", "") // HTML 태그 제거
.replaceAll("&nbsp;", " ") // HTML 엔티티 처리
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&amp;", "&")
.replaceAll("\\s+", " ") // 연속된 공백을 하나로
.trim();
}
/**
* HTML 태그 제거 번째 의미있는 문장 추출
* 번째 의미있는 문장 추출
*/
private String extractFirstSentence(String htmlContent) {
// HTML 태그 모두 제거
String cleanContent = htmlContent.replaceAll("<[^>]+>", "").trim();
private String extractFirstMeaningfulSentence(String cleanText) {
if (cleanText == null || cleanText.trim().isEmpty()) {
return "마케팅 팁이 생성되었습니다.";
}
// 줄별로 나누어서 번째 의미있는 찾기
String[] lines = cleanContent.split("\\n");
// 문장 분할 (마침표, 느낌표, 물음표 기준)
String[] sentences = cleanText.split("[.!?]");
for (String line : lines) {
line = line.trim();
for (String sentence : sentences) {
String trimmed = sentence.trim();
// 줄이나 이모지만 있는 건너뛰기
if (line.isEmpty() || line.matches("^[\\p{So}\\p{Sk}\\s]+$")) {
continue;
}
// 의미있는 문장인지 확인 (10자 이상, 특수문자만으로 구성되지 않음)
if (trimmed.length() >= 10 &&
!trimmed.matches("^[\\s\\p{Punct}]*$") && // 공백과 구두점만으로 구성되지 않음
!isOnlyEmojisOrSymbols(trimmed)) { // 이모지나 기호만으로 구성되지 않음
// 최소 길이 체크하고 반환
if (line.length() > 5) {
// 50자 제한
if (line.length() > 50) {
return line.substring(0, 50).trim() + "...";
// 문장 끝에 마침표 추가 (없는 경우)
if (!trimmed.endsWith(".") && !trimmed.endsWith("!") && !trimmed.endsWith("?")) {
trimmed += ".";
}
return line;
return trimmed;
}
}
// 모든 방법이 실패하면 기존 방식 사용
String[] sentences = cleanContent.split("[.!?]");
String firstSentence = sentences.length > 0 ? sentences[0].trim() : cleanContent;
if (firstSentence.length() > 50) {
firstSentence = firstSentence.substring(0, 50).trim() + "...";
// 의미있는 문장을 찾지 못한 경우 원본의 처음 50자 반환
if (cleanText.length() > 50) {
return cleanText.substring(0, 47) + "...";
}
return firstSentence.isEmpty() ? "마케팅 팁이 생성되었습니다." : firstSentence;
return cleanText;
}
/**
* 이모지나 기호만으로 구성되었는지 확인
*/
private boolean isOnlyEmojisOrSymbols(String text) {
// 한글, 영문, 숫자가 포함되어 있으면 의미있는 텍스트로 판단
return !text.matches(".*[\\p{L}\\p{N}].*");
}
/**
* 핵심 섹션을 찾지 못한 경우 대체 요약 생성
*/
private String extractFallbackSummary(String fullContent) {
// HTML 태그 제거 번째 의미있는 문장 찾기
String cleanContent = removeHtmlTags(fullContent);
// 번째 문단에서 의미있는 문장 추출
String[] paragraphs = cleanContent.split("\\n\\n");
for (String paragraph : paragraphs) {
String trimmed = paragraph.trim();
if (trimmed.length() >= 20) { // 충분히 문단
String summary = extractFirstMeaningfulSentence(trimmed);
if (summary.length() >= 10) {
return summary;
}
}
}
// 모든 방법이 실패한 경우 기본 메시지
return "개인화된 마케팅 팁이 생성되었습니다.";
}
/**

View File

@ -43,10 +43,18 @@ management:
endpoints:
web:
exposure:
include: health,info,metrics
include: health,info
base-path: /actuator
endpoint:
health:
show-details: always
info:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
logging:
level:
@ -56,3 +64,10 @@ jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}
info:
app:
name: ${APP_NAME:smarketing-recommend}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - recommend"

View File

@ -35,6 +35,7 @@ subprojects {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
@ -51,6 +52,7 @@ subprojects {
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'
}
tasks.named('test') {

View File

@ -45,7 +45,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
"/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
"/swagger-resources/**", "/webjars/**").permitAll()
"/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

View File

@ -41,6 +41,23 @@ podTemplate(
echo "Image Tag: ${imageTag}"
}
stage("Check Changes") {
script {
def changes = sh(
script: "git diff --name-only HEAD~1 HEAD",
returnStdout: true
).trim()
if (!changes.contains("smarketing-java/")) {
echo "No changes in smarketing-java, skipping build"
currentBuild.result = 'SUCCESS'
error("Stopping pipeline - no changes detected")
}
echo "Changes detected in smarketing-java, proceeding with build"
}
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
@ -49,8 +66,8 @@ podTemplate(
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 인증정보 가져오기 ==="
az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing
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 -
@ -66,6 +83,9 @@ podTemplate(
echo "=== 클러스터 상태 확인 ==="
kubectl get nodes
kubectl get ns ${namespace}
echo "=== 현재 연결된 클러스터 확인 ==="
kubectl config current-context
"""
}
}
@ -99,7 +119,7 @@ podTemplate(
timeout 30 sh -c 'until docker info; do sleep 1; done'
"""
// 🔧 ACR Credential을 Jenkins에서 직접 사용
// ACR Credential을 Jenkins에서 직접 사용
withCredentials([usernamePassword(
credentialsId: 'acr-credentials',
usernameVariable: 'ACR_USERNAME',
@ -184,6 +204,10 @@ podTemplate(
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 서비스가 없습니다. 먼저 설치해주세요."

View File

@ -8,6 +8,16 @@ data:
ALLOWED_ORIGINS: ${allowed_origins}
JPA_DDL_AUTO: update
JPA_SHOW_SQL: 'true'
# 🔧 강화된 Actuator 설정
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: '*'
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always
MANAGEMENT_ENDPOINT_HEALTH_ENABLED: 'true'
MANAGEMENT_ENDPOINTS_WEB_BASE_PATH: /actuator
MANAGEMENT_SERVER_PORT: '8080'
# Spring Security 비활성화 (Actuator용)
SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
# 또는 Management port를 main port와 동일하게
MANAGEMENT_SERVER_PORT: ''
---
apiVersion: v1
@ -167,18 +177,29 @@ spec:
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 10
# 🔧 개선된 Health Check 설정
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 60
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120 # 2분으로 증가
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health
path: /actuator/health/readiness
port: 8081
initialDelaySeconds: 30
periodSeconds: 5
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60 # 1분으로 증가
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: apps/v1
@ -236,14 +257,24 @@ spec:
httpGet:
path: /actuator/health
port: 8082
initialDelaySeconds: 60
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health
path: /actuator/health/readiness
port: 8082
initialDelaySeconds: 30
periodSeconds: 5
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: apps/v1
@ -301,14 +332,24 @@ spec:
httpGet:
path: /actuator/health
port: 8083
initialDelaySeconds: 60
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health
path: /actuator/health/readiness
port: 8083
initialDelaySeconds: 30
periodSeconds: 5
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: apps/v1
@ -366,14 +407,24 @@ spec:
httpGet:
path: /actuator/health
port: 8084
initialDelaySeconds: 60
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health
path: /actuator/health/readiness
port: 8084
initialDelaySeconds: 30
periodSeconds: 5
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
# Services

View File

@ -16,6 +16,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
/**
@ -39,18 +40,13 @@ public class PosterContentService implements PosterContentUseCase {
@Override
@Transactional
public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) {
// AI를 사용하여 포스터 생성
String generatedPoster = aiPosterGenerator.generatePoster(request);
// 다양한 사이즈의 포스터 생성
Map<String, String> posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster);
String generatedPoster = aiPosterGenerator.generatePoster(request);
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
// .toneAndManner(request.getToneAndManner())
// .emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
@ -62,9 +58,8 @@ public class PosterContentService implements PosterContentUseCase {
.contentType(ContentType.POSTER.name())
.title(request.getTitle())
.posterImage(generatedPoster)
.posterSizes(posterSizes)
.posterSizes(new HashMap<>()) // 반환 (사이즈 변환 안함)
.status(ContentStatus.DRAFT.name())
//.createdAt(LocalDateTime.now())
.build();
}
@ -76,33 +71,28 @@ public class PosterContentService implements PosterContentUseCase {
@Override
@Transactional
public void savePosterContent(PosterContentSaveRequest request) {
// 생성 조건 정보 구성
// 생성 조건 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
// .toneAndManner(request.getToneAndManner())
// .emotionIntensity(request.getEmotionIntensity())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.photoStyle(request.getPhotoStyle())
.build();
// 콘텐츠 엔티티 생성 저장
// 콘텐츠 엔티티 생성
Content content = Content.builder()
.contentType(ContentType.POSTER)
.platform(Platform.GENERAL) // 포스터는 범용
.title(request.getTitle())
.content(null) // 포스터는 이미지가 콘텐츠
.hashtags(null)
.content(request.getContent())
.images(request.getImages())
.status(ContentStatus.PUBLISHED)
.creationConditions(conditions)
.storeId(request.getStoreId())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.build();
// 저장
contentRepository.save(content);
}
}

View File

@ -1,86 +0,0 @@
// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* Claude AI를 활용한 포스터 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class ClaudeAiPosterGenerator implements AiPosterGenerator {
/**
* 포스터 생성
*
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
@Override
public String generatePoster(PosterContentCreateRequest request) {
try {
// Claude AI API 호출 로직
String prompt = buildPosterPrompt(request);
// TODO: 실제 Claude AI API 호출
// 현재는 더미 데이터 반환
return generateDummyPosterUrl(request.getTitle());
} catch (Exception e) {
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
return generateFallbackPosterUrl();
}
}
/**
* 다양한 사이즈의 포스터 생성
*
* @param baseImage 기본 이미지
* @return 사이즈별 포스터 URL
*/
@Override
public Map<String, String> generatePosterSizes(String baseImage) {
Map<String, String> sizes = new HashMap<>();
// 다양한 사이즈 생성 (더미 구현)
sizes.put("instagram_square", baseImage + "_1080x1080.jpg");
sizes.put("instagram_story", baseImage + "_1080x1920.jpg");
sizes.put("facebook_post", baseImage + "_1200x630.jpg");
sizes.put("a4_poster", baseImage + "_2480x3508.jpg");
return sizes;
}
private String buildPosterPrompt(PosterContentCreateRequest request) {
StringBuilder prompt = new StringBuilder();
prompt.append("포스터 제목: ").append(request.getTitle()).append("\n");
prompt.append("카테고리: ").append(request.getCategory()).append("\n");
if (request.getRequirement() != null) {
prompt.append("요구사항: ").append(request.getRequirement()).append("\n");
}
if (request.getToneAndManner() != null) {
prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n");
}
return prompt.toString();
}
private String generateDummyPosterUrl(String title) {
return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg";
}
private String generateFallbackPosterUrl() {
return "https://dummy-ai-service.com/posters/fallback.jpg";
}
}

View File

@ -0,0 +1,152 @@
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
/**
* Claude AI를 활용한 포스터 생성 구현체
* Clean Architecture의 Infrastructure Layer에 위치
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class PythonAiPosterGenerator implements AiPosterGenerator {
private final WebClient webClient;
@Value("${external.ai-service.base-url}")
private String aiServiceBaseUrl;
/**
* 포스터 생성 - Python AI 서비스 호출
*
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
@Override
public String generatePoster(PosterContentCreateRequest request) {
try {
log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl);
// 요청 데이터 구성
Map<String, Object> requestBody = buildRequestBody(request);
log.debug("포스터 생성 요청 데이터: {}", requestBody);
// Python AI 서비스 호출
Map<String, Object> response = webClient
.post()
.uri(aiServiceBaseUrl + "/api/ai/poster")
.header("Content-Type", "application/json")
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 있음
.block();
// 응답에서 content(이미지 URL) 추출
if (response != null && response.containsKey("content")) {
String imageUrl = (String) response.get("content");
log.info("AI 포스터 생성 성공: imageUrl={}", imageUrl);
return imageUrl;
} else {
log.warn("AI 포스터 생성 응답에 content가 없음: {}", response);
return generateFallbackPosterUrl(request.getTitle());
}
} catch (Exception e) {
log.error("AI 포스터 생성 실패: {}", e.getMessage(), e);
return generateFallbackPosterUrl(request.getTitle());
}
}
/**
* 다양한 사이즈의 포스터 생성 (사용하지 않음)
* 1개의 이미지만 생성하므로 반환
*
* @param baseImage 기본 이미지 URL
* @return
*/
@Override
public Map<String, String> generatePosterSizes(String baseImage) {
log.info("포스터 사이즈 변환 기능은 사용하지 않음: baseImage={}", baseImage);
return new HashMap<>();
}
/**
* Python AI 서비스 요청 데이터 구성
* Python 서비스의 PosterContentGetRequest 모델에 맞춤
*/
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request) {
Map<String, Object> requestBody = new HashMap<>();
// 기본 정보
requestBody.put("title", request.getTitle());
requestBody.put("category", request.getCategory());
requestBody.put("contentType", request.getContentType());
// 이미지 정보
if (request.getImages() != null && !request.getImages().isEmpty()) {
requestBody.put("images", request.getImages());
}
// 스타일 정보
if (request.getPhotoStyle() != null) {
requestBody.put("photoStyle", request.getPhotoStyle());
}
// 요구사항
if (request.getRequirement() != null) {
requestBody.put("requirement", request.getRequirement());
}
// 톤앤매너
if (request.getToneAndManner() != null) {
requestBody.put("toneAndManner", request.getToneAndManner());
}
// 감정 강도
if (request.getEmotionIntensity() != null) {
requestBody.put("emotionIntensity", request.getEmotionIntensity());
}
// 메뉴명
if (request.getMenuName() != null) {
requestBody.put("menuName", request.getMenuName());
}
// 이벤트 정보
if (request.getEventName() != null) {
requestBody.put("eventName", request.getEventName());
}
// 날짜 정보 (LocalDate를 String으로 변환)
if (request.getStartDate() != null) {
requestBody.put("startDate", request.getStartDate().format(DateTimeFormatter.ISO_LOCAL_DATE));
}
if (request.getEndDate() != null) {
requestBody.put("endDate", request.getEndDate().format(DateTimeFormatter.ISO_LOCAL_DATE));
}
return requestBody;
}
/**
* 폴백 포스터 URL 생성
*/
private String generateFallbackPosterUrl(String title) {
// 기본 포스터 템플릿 URL 반환
return "https://stdigitalgarage02.blob.core.windows.net/ai-content/fallback-poster.jpg";
}
}

View File

@ -37,3 +37,26 @@ logging:
external:
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://20.249.139.88:5001}
management:
endpoints:
web:
exposure:
include: health,info
base-path: /actuator
endpoint:
health:
show-details: always
info:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
info:
app:
name: ${APP_NAME:smarketing-content}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - content"

View File

@ -31,3 +31,26 @@ jwt:
logging:
level:
com.won.smarketing: ${LOG_LEVEL:DEBUG}
management:
endpoints:
web:
exposure:
include: health,info
base-path: /actuator
endpoint:
health:
show-details: always
info:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
info:
app:
name: ${APP_NAME:smarketing-member}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - member"

View File

@ -8,7 +8,6 @@ import lombok.NoArgsConstructor;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Size;
import org.springframework.web.multipart.MultipartFile;
/**
* 메뉴 수정 요청 DTO

View File

@ -11,7 +11,6 @@ import com.won.smarketing.store.repository.MenuRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.stream.Collectors;
@ -117,6 +116,7 @@ public class MenuServiceImpl implements MenuService {
.menuId(menu.getMenuId())
.menuName(menu.getMenuName())
.category(menu.getCategory())
.image(menu.getImage())
.price(menu.getPrice())
.description(menu.getDescription())
.createdAt(menu.getCreatedAt())

View File

@ -46,3 +46,26 @@ azure:
menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images}
store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images}
max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB
management:
endpoints:
web:
exposure:
include: health,info
base-path: /actuator
endpoint:
health:
show-details: always
info:
enabled: true
health:
livenessState:
enabled: true
readinessState:
enabled: true
info:
app:
name: ${APP_NAME:smarketing-content}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - content"