Merge branch 'main' into marketing-contents

This commit is contained in:
박서은
2025-06-19 17:42:54 +09:00
64 changed files with 1960 additions and 670 deletions
+1
View File
@@ -0,0 +1 @@
# GitOps Test Thu Jun 19 05:36:03 UTC 2025
@@ -0,0 +1,88 @@
package com.won.smarketing.recommend.config;
import com.won.smarketing.common.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정 클래스
* JWT 기반 인증 및 CORS 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig
{
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${allowed-origins}")
private String allowedOrigins;
/**
* Spring Security 필터 체인 설정
*
* @param http HttpSecurity 객체
* @return SecurityFilterChain
* @throws Exception 예외
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.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/**", "/actuator/**", "/health/**", "/error"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 패스워드 인코더 빈 등록
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* CORS 설정
*
* @return CorsConfigurationSource
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -26,10 +26,10 @@ spring:
external:
store-service:
base-url: ${STORE_SERVICE_URL:http://localhost:8082}
base-url: ${STORE_SERVICE_URL:http://smarketing.20.249.184.228.nip.io}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service:
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001}
base-url: ${PYTHON_AI_SERVICE_URL:http://20.249.113.247:5001}
api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000}
@@ -70,4 +70,6 @@ info:
app:
name: ${APP_NAME:smarketing-recommend}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - recommend"
description: "AI 마케팅 서비스 MVP - recommend"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
+9
View File
@@ -53,6 +53,15 @@ subprojects {
implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0'
implementation 'com.azure:azure-identity:1.11.4'
// Azure Blob Storage 의존성 추가
implementation 'com.azure:azure-storage-blob:12.25.0'
implementation 'com.azure:azure-identity:1.11.1'
implementation 'com.fasterxml.jackson.core:jackson-core'
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
tasks.named('test') {
+186 -181
View File
@@ -1,3 +1,5 @@
// smarketing-backend/smarketing-java/deployment/Jenkinsfile
def PIPELINE_ID = "${env.BUILD_NUMBER}"
def getImageTag() {
@@ -12,230 +14,233 @@ podTemplate(
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')
containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true)
],
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']
// Manifest Repository 설정
def MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git'
def MANIFEST_CREDENTIAL_ID = 'github-credentials-smarketing'
stage("Get Source") {
checkout scm
// smarketing-java 하위에 있는 설정 파일 읽기
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
namespace = "${props.namespace}"
try {
stage("Get Source") {
checkout scm
// smarketing-java 하위에 있는 설정 파일 읽기
props = readProperties file: "smarketing-java/deployment/deploy_env_vars"
echo "=== Build Information ==="
echo "Services: ${services}"
echo "Namespace: ${namespace}"
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"
echo "=== Build Information ==="
echo "Services: ${services}"
echo "Image Tag: ${imageTag}"
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
}
}
stage("Setup AKS") {
container('azure-cli') {
withCredentials([azureServicePrincipal('azure-credentials')]) {
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('Build Applications') {
container('gradle') {
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 "=== smarketing-java 디렉토리로 이동 ==="
cd smarketing-java
echo "=== AKS 인증정보 가져오기 (rg-digitalgarage-02) ==="
az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing
echo "=== gradlew 권한 설정 ==="
chmod +x gradlew
echo "=== 네임스페이스 생성 ==="
kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -
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 "=== 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
echo "=== 빌드 결과 확인 ==="
find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar'
"""
}
}
}
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'
)]) {
stage('Build & Push Images') {
container('docker') {
sh """
echo "=== Docker로 ACR 로그인 ==="
echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin
echo "=== Docker 데몬 시작 대기 ==="
timeout 30 sh -c 'until docker info; do sleep 1; done'
"""
services.each { service ->
script {
def buildDir = "smarketing-java/${service}"
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
// 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
"""
echo "Building image for ${service}: ${fullImageName}"
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('Update Manifest Repository') {
container('git') {
script {
// Manifest Repository Clone
withCredentials([usernamePassword(
credentialsId: MANIFEST_CREDENTIAL_ID,
usernameVariable: 'GIT_USERNAME',
passwordVariable: 'GIT_PASSWORD'
)]) {
sh """
echo "=== Git 설정 ==="
git config --global user.name "Jenkins CI"
git config --global user.email "jenkins@company.com"
echo "=== Manifest Repository Clone ==="
rm -rf manifest-repo
git clone https://\$GIT_USERNAME:\$GIT_PASSWORD@github.com/won-ktds/smarketing-manifest.git manifest-repo
cd manifest-repo
"""
// 실제 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 파일을 찾을 수 없습니다"
services.each { service ->
def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}"
def deploymentFile = "smarketing/deployments/${service}/${service}-deployment.yaml"
sh """
cd manifest-repo
echo "=== ${service} 이미지 태그 업데이트 ==="
if [ -f "${deploymentFile}" ]; then
# 이미지 태그 업데이트 (sed 사용)
sed -i 's|image: ${props.registry}/${props.image_org}/${service}:.*|image: ${fullImageName}|g' "${deploymentFile}"
echo "Updated ${deploymentFile} with new image: ${fullImageName}"
# 변경사항 확인
echo "=== 변경된 내용 확인 ==="
grep "image: ${props.registry}/${props.image_org}/${service}" "${deploymentFile}" || echo "이미지 태그 업데이트 확인 실패"
else
echo "Warning: ${deploymentFile} not found"
fi
"""
}
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}
cd manifest-repo
echo "Successfully built and pushed: ${fullImageName}"
echo "=== Git 변경사항 확인 ==="
git status
git diff
# 변경사항이 있으면 커밋 및 푸시
if [ -n "\$(git status --porcelain)" ]; then
git add .
git commit -m "Update SMarketing services to ${imageTag} - Build ${env.BUILD_NUMBER}"
git push origin main
echo "✅ Successfully updated manifest repository"
else
echo "️ No changes to commit"
fi
"""
}
}
}
}
}
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
stage('Trigger ArgoCD Sync') {
script {
echo """
🎯 CI Pipeline 완료!
# 이미지 경로 환경변수 설정
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}
📦 빌드된 이미지들:
${services.collect { "- ${props.registry}/${props.image_org}/${it}:${imageTag}" }.join('\n')}
echo "=== Manifest 생성 ==="
envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest}
echo "=== Generated Manifest File ==="
cat smarketing-java/deployment/${manifest}
echo "==============================="
"""
🔄 ArgoCD 동작:
- ArgoCD가 manifest repository 변경사항을 자동으로 감지합니다
- 각 서비스별 Application이 새로운 이미지로 동기화됩니다
- ArgoCD UI에서 배포 상태를 모니터링하세요
🌐 ArgoCD UI: [ArgoCD 접속 URL]
📁 Manifest Repo: ${MANIFEST_REPO}
"""
}
}
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 """
✅ CI Pipeline 성공!
🏷️ 새로운 이미지 태그: ${imageTag}
🔄 ArgoCD가 자동으로 배포를 시작합니다
"""
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
"""
} catch (Exception e) {
// 실패 시 처리
echo "❌ CI Pipeline 실패: ${e.getMessage()}"
throw e
} finally {
// 정리 작업 (항상 실행)
container('docker') {
sh 'docker system prune -f || true'
}
sh 'rm -rf manifest-repo || true'
}
}
}
@@ -0,0 +1,301 @@
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}"
echo "Registry: ${props.registry}"
echo "Image Org: ${props.image_org}"
}
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')]) {
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}
# PostgreSQL 환경변수 추가 (올바른 DB명으로 수정)
export postgres_host='${props.POSTGRES_HOST}'
export postgres_port='5432'
export postgres_db_member='MemberDB'
export postgres_db_store='StoreDB'
export postgres_db_marketing_content='MarketingContentDB'
export postgres_db_ai_recommend='AiRecommendationDB'
# Redis 환경변수 추가
export redis_host='${props.REDIS_HOST}'
export redis_port='6380'
export redis_password='${props.REDIS_PASSWORD}'
# 리소스 요구사항
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 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 "=== 환경변수 확인 ==="
echo "namespace: \$namespace"
echo "postgres_host: \$postgres_host"
echo "postgres_port: \$postgres_port"
echo "postgres_user: \$postgres_user"
echo "postgres_db_member: \$postgres_db_member"
echo "postgres_db_store: \$postgres_db_store"
echo "postgres_db_marketing_content: \$postgres_db_marketing_content"
echo "postgres_db_ai_recommend: \$postgres_db_ai_recommend"
echo "redis_host: \$redis_host"
echo "redis_port: \$redis_port"
echo "replicas: \$replicas"
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 "=== 기존 ConfigMap 삭제 (타입 충돌 해결) ==="
kubectl delete configmap member-config store-config marketing-content-config ai-recommend-config -n ${namespace} --ignore-not-found=true
echo "=== PostgreSQL 서비스 확인 ==="
kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스를 찾을 수 없습니다."
echo "=== Redis 서비스 확인 ==="
kubectl get svc -n ${namespace} | grep redis || echo "Redis 서비스를 찾을 수 없습니다."
echo "=== Manifest 적용 ==="
kubectl apply -f smarketing-java/deployment/${manifest}
echo "=== 배포 상태 확인 (30초 대기) ==="
sleep 30
kubectl -n ${namespace} get deployments
kubectl -n ${namespace} get pods
echo "=== ConfigMap 확인 ==="
kubectl -n ${namespace} get configmap member-config -o yaml | grep -A 10 "data:"
kubectl -n ${namespace} get configmap ai-recommend-config -o yaml | grep -A 10 "data:"
echo "=== Secret 확인 ==="
kubectl -n ${namespace} get secret member-secret -o yaml | grep -A 5 "data:"
echo "=== 각 서비스 배포 대기 (120초 timeout) ==="
timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=120s || echo "member deployment 대기 타임아웃"
timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=120s || echo "store deployment 대기 타임아웃"
timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=120s || echo "marketing-content deployment 대기 타임아웃"
timeout 120 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=120s || echo "ai-recommend deployment 대기 타임아웃"
echo "=== 최종 배포 상태 ==="
kubectl -n ${namespace} get all
echo "=== 각 서비스 Pod 로그 확인 (최근 20라인) ==="
for service in member store marketing-content ai-recommend; do
echo "=== \$service 서비스 로그 ==="
kubectl -n ${namespace} logs deployment/\$service --tail=20 || echo "\$service 로그를 가져올 수 없습니다"
echo ""
done
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 -30
echo "=== Pod 로그: \$pod ==="
kubectl -n ${namespace} logs \$pod --tail=50 || echo "로그를 가져올 수 없습니다"
echo "=========================================="
fi
done
echo "=== Ingress 상태 확인 ==="
kubectl -n ${namespace} get ingress
kubectl -n ${namespace} describe ingress smarketing-backend || echo "Ingress를 찾을 수 없습니다"
echo "=== 서비스 Endpoint 확인 ==="
kubectl -n ${namespace} get endpoints
"""
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
## Globally shared configuration
global:
# -- Default domain used by all components
## Used for ingresses, certificates, SSO, notifications, etc.
## IP는 외부에서 접근할 수 있는 ks8 node의 Public IP 또는
## ingress-nginx-controller 서비스의 External IP이여야 함
domain: argo.20.249.184.228.nip.io
# -- 특정 노드에 배포시 지정
#nodeSelector:
#agentpool: argocd
server:
ingress:
enabled: true
https: true
annotations:
kubernetes.io/ingress.class: nginx
tls:
- secretName: argocd-tls-smarketing-secret
extraArgs:
- --insecure # ArgoCD 서버가 TLS 종료를 Ingress에 위임
configs:
params:
server.insecure: true # Ingress에서 TLS를 처리하므로 ArgoCD 서버는 HTTP로 통신
certificate:
enabled: false # 자체 서명 인증서 사용 비활성화 (외부 인증서 사용 시)
@@ -8,16 +8,11 @@ data:
ALLOWED_ORIGINS: ${allowed_origins}
JPA_DDL_AUTO: update
JPA_SHOW_SQL: 'true'
# 🔧 강화된 Actuator 설정
# 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
@@ -26,10 +21,14 @@ metadata:
name: member-config
namespace: ${namespace}
data:
POSTGRES_DB: member
POSTGRES_HOST: member-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8081'
POSTGRES_HOST: ${postgres_host}
POSTGRES_PORT: '5432'
POSTGRES_DB: ${postgres_db_member}
REDIS_HOST: ${redis_host}
REDIS_PORT: '6380'
JPA_DDL_AUTO: 'create-drop'
JPA_SHOW_SQL: 'true'
---
apiVersion: v1
@@ -38,10 +37,14 @@ metadata:
name: store-config
namespace: ${namespace}
data:
POSTGRES_DB: store
POSTGRES_HOST: store-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8082'
POSTGRES_HOST: ${postgres_host}
POSTGRES_PORT: '5432'
POSTGRES_DB: ${postgres_db_store}
REDIS_HOST: ${redis_host}
REDIS_PORT: '6380'
JPA_DDL_AUTO: 'create-drop'
JPA_SHOW_SQL: 'true'
---
apiVersion: v1
@@ -50,10 +53,14 @@ metadata:
name: marketing-content-config
namespace: ${namespace}
data:
POSTGRES_DB: marketing_content
POSTGRES_HOST: marketing-content-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8083'
POSTGRES_HOST: ${postgres_host}
POSTGRES_PORT: '5432'
POSTGRES_DB: ${postgres_db_marketing_content}
REDIS_HOST: ${redis_host}
REDIS_PORT: '6380'
JPA_DDL_AUTO: 'create-drop'
JPA_SHOW_SQL: 'true'
---
apiVersion: v1
@@ -62,10 +69,14 @@ metadata:
name: ai-recommend-config
namespace: ${namespace}
data:
POSTGRES_DB: ai_recommend
POSTGRES_HOST: ai-recommend-postgresql
POSTGRES_PORT: '5432'
SERVER_PORT: '8084'
POSTGRES_HOST: ${postgres_host}
POSTGRES_PORT: '5432'
POSTGRES_DB: ${postgres_db_ai_recommend}
REDIS_HOST: ${redis_host}
REDIS_PORT: '6380'
JPA_DDL_AUTO: 'create-drop'
JPA_SHOW_SQL: 'true'
---
# Secrets
@@ -87,8 +98,9 @@ metadata:
stringData:
JWT_ACCESS_TOKEN_VALIDITY: '3600000'
JWT_REFRESH_TOKEN_VALIDITY: '86400000'
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
POSTGRES_PASSWORD: ${postgres_password}
REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -98,8 +110,9 @@ metadata:
name: store-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
POSTGRES_PASSWORD: ${postgres_password}
REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -109,8 +122,9 @@ metadata:
name: marketing-content-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
POSTGRES_PASSWORD: ${postgres_password}
REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -120,8 +134,9 @@ metadata:
name: ai-recommend-secret
namespace: ${namespace}
stringData:
POSTGRES_PASSWORD: ${postgres_password}
POSTGRES_USER: ${postgres_user}
POSTGRES_PASSWORD: ${postgres_password}
REDIS_PASSWORD: ${redis_password}
type: Opaque
---
@@ -167,39 +182,6 @@ spec:
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
# 🔧 개선된 Health Check 설정
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120 # 2분으로 증가
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8081
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60 # 1분으로 증가
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: apps/v1
@@ -243,38 +225,7 @@ spec:
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
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8082
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: apps/v1
@@ -318,38 +269,7 @@ spec:
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
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8083
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
apiVersion: apps/v1
@@ -393,38 +313,7 @@ spec:
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
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8084
httpHeaders:
- name: Accept
value: application/json
initialDelaySeconds: 60
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
---
# Services
@@ -487,14 +376,15 @@ spec:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: smarketing-backend
name: smarketing-ingress
namespace: ${namespace}
annotations:
kubernetes.io/ingress.class: nginx
spec:
ingressClassName: nginx
rules:
- http:
- host: smarketing.20.249.184.228.nip.io
http:
paths:
- path: /api/auth
pathType: Prefix
@@ -524,3 +414,4 @@ spec:
name: ai-recommend
port:
number: 80
+2 -1
View File
@@ -8,8 +8,9 @@ registry=acrdigitalgarage02.azurecr.io
image_org=smarketing
# Application Settings
ingress_host=smarketing.20.249.184.228.nip.io
replicas=1
allowed_origins=http://20.249.171.38
allowed_origins=http://20.249.154.194
# Security Settings
jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ
@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: member
namespace: smarketing
spec:
replicas: 2
selector:
matchLabels:
app: member
template:
metadata:
labels:
app: member
spec:
containers:
- name: member
image: acrdigitalgarage02.azurecr.io/member:latest
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "k8s"
resources:
requests:
memory: "256Mi"
cpu: "256m"
limits:
memory: "1024Mi"
cpu: "1024m"
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: member
namespace: smarketing
spec:
selector:
app: member
ports:
- port: 80
targetPort: 8080
type: ClusterIP
@@ -1,7 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'org.postgresql:postgresql'
// WebClient를 위한 Spring WebFlux 의존성
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
@@ -6,30 +6,41 @@ import com.won.smarketing.content.domain.model.ContentStatus;
import com.won.smarketing.content.domain.model.ContentType;
import com.won.smarketing.content.domain.model.CreationConditions;
import com.won.smarketing.content.domain.model.Platform;
import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.domain.repository.ContentRepository;
import com.won.smarketing.content.domain.service.AiPosterGenerator;
import com.won.smarketing.content.domain.service.BlobStorageService;
import com.won.smarketing.content.domain.service.StoreDataProvider;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.List;
/**
* 포스터 콘텐츠 서비스 구현체
* 홍보 포스터 생성 및 저장 기능 구현
*/
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PosterContentService implements PosterContentUseCase {
@Value("${azure.storage.container.poster-images:poster-images}")
private String posterImageContainer;
private final ContentRepository contentRepository;
private final AiPosterGenerator aiPosterGenerator;
private final BlobStorageService blobStorageService;
private final StoreDataProvider storeDataProvider;
/**
* 포스터 콘텐츠 생성
@@ -39,26 +50,24 @@ public class PosterContentService implements PosterContentUseCase {
*/
@Override
@Transactional
public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) {
public PosterContentCreateResponse generatePosterContent(List<MultipartFile> images, PosterContentCreateRequest request) {
String generatedPoster = aiPosterGenerator.generatePoster(request);
// 1. 이미지 blob storage에 저장하고 request 저장
List<String> imageUrls = blobStorageService.uploadImage(images, posterImageContainer);
request.setImages(imageUrls);
// 매장 정보 호출
String userId = getCurrentUserId();
StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId);
// 생성 조건 정보 구성
CreationConditions conditions = CreationConditions.builder()
.category(request.getCategory())
.requirement(request.getRequirement())
.eventName(request.getEventName())
.startDate(request.getStartDate())
.endDate(request.getEndDate())
.photoStyle(request.getPhotoStyle())
.build();
// 2. AI 요청
String generatedPoster = aiPosterGenerator.generatePoster(request, storeWithMenuData);
return PosterContentCreateResponse.builder()
.contentId(null) // 임시 생성이므로 ID 없음
.contentType(ContentType.POSTER.name())
.title(request.getTitle())
.posterImage(generatedPoster)
.posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함)
.content(generatedPoster)
.status(ContentStatus.DRAFT.name())
.build();
}
@@ -68,7 +77,6 @@ public class PosterContentService implements PosterContentUseCase {
*
* @param request 포스터 콘텐츠 저장 요청
*/
@Override
@Transactional
public void savePosterContent(PosterContentSaveRequest request) {
// 생성 조건 구성
@@ -96,4 +104,11 @@ public class PosterContentService implements PosterContentUseCase {
// 저장
contentRepository.save(content);
}
/**
* 현재 로그인된 사용자 ID 조회
*/
private String getCurrentUserId() {
return SecurityContextHolder.getContext().getAuthentication().getName();
}
}
@@ -14,6 +14,7 @@ import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest;
import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse;
import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@@ -34,6 +35,9 @@ public class SnsContentService implements SnsContentUseCase {
private final AiContentGenerator aiContentGenerator;
private final BlobStorageService blobStorageService;
@Value("${azure.storage.container.poster-images:content-images}")
private String contentImageContainer;
/**
* SNS 콘텐츠 생성
*
@@ -44,8 +48,10 @@ public class SnsContentService implements SnsContentUseCase {
@Transactional
public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request, List<MultipartFile> files) {
//파일들 주소 가져옴
List<String> urls = blobStorageService.uploadImage(files);
request.setImages(urls);
if(files != null) {
List<String> urls = blobStorageService.uploadImage(files, contentImageContainer);
request.setImages(urls);
}
// AI를 사용하여 SNS 콘텐츠 생성
String content = aiContentGenerator.generateSnsContent(request);
@@ -1,9 +1,13 @@
// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java
package com.won.smarketing.content.application.usecase;
import com.won.smarketing.content.domain.model.Content;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse;
import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/**
* 포스터 콘텐츠 관련 UseCase 인터페이스
@@ -16,7 +20,7 @@ public interface PosterContentUseCase {
* @param request 포스터 콘텐츠 생성 요청
* @return 포스터 콘텐츠 생성 응답
*/
PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request);
PosterContentCreateResponse generatePosterContent(List<MultipartFile> images, PosterContentCreateRequest request);
/**
* 포스터 콘텐츠 저장
@@ -0,0 +1,88 @@
package com.won.smarketing.content.config;
import com.won.smarketing.common.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정 클래스
* JWT 기반 인증 및 CORS 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig
{
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${allowed-origins}")
private String allowedOrigins;
/**
* Spring Security 필터 체인 설정
*
* @param http HttpSecurity 객체
* @return SecurityFilterChain
* @throws Exception 예외
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.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/**", "/actuator/**", "/health/**", "/error"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 패스워드 인코더 빈 등록
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* CORS 설정
*
* @return CorsConfigurationSource
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -1,4 +1,3 @@
// marketing-content/src/main/java/com/won/smarketing/content/config/WebClientConfig.java
package com.won.smarketing.content.config;
import org.springframework.context.annotation.Bean;
@@ -20,8 +19,8 @@ public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 50000)
.responseTimeout(Duration.ofMillis(300000));
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 15000) // 연결 타임아웃: 15초
.responseTimeout(Duration.ofMinutes(5)); // 응답 타임아웃: 5분
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
@@ -27,42 +27,37 @@ import java.util.List;
@Builder
public class Content {
// ==================== 기본키 및 식별자 ====================
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "content_id")
private Long id;
// ==================== 콘텐츠 분류 ====================
private ContentType contentType;
private Platform platform;
// ==================== 콘텐츠 내용 ====================
private String title;
private String content;
// ==================== 멀티미디어 및 메타데이터 ====================
@Builder.Default
private List<String> hashtags = new ArrayList<>();
@Builder.Default
private List<String> images = new ArrayList<>();
// ==================== 상태 관리 ====================
private ContentStatus status;
// ==================== 생성 조건 ====================
private CreationConditions creationConditions;
// ==================== 매장 정보 ====================
private Long storeId;
// ==================== 프로모션 기간 ====================
private LocalDateTime promotionStartDate;
private LocalDateTime promotionEndDate;
// ==================== 메타데이터 ====================
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List<String> strings, List<String> strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) {
@@ -24,8 +24,6 @@ public class CreationConditions {
private String id;
private String category;
private String requirement;
// private String toneAndManner;
// private String emotionIntensity;
private String storeName;
private String storeType;
private String target;
@@ -0,0 +1,21 @@
package com.won.smarketing.content.domain.model.store;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 메뉴 데이터 값 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MenuData {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
}
@@ -0,0 +1,22 @@
package com.won.smarketing.content.domain.model.store;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 데이터 값 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
private Long storeId;
private String storeName;
private String businessType;
private String location;
private String description;
private Integer seatCount;
}
@@ -0,0 +1,13 @@
package com.won.smarketing.content.domain.model.store;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class StoreWithMenuData {
private StoreData storeData;
private List<MenuData> menuDataList;
}
@@ -1,5 +1,6 @@
package com.won.smarketing.content.domain.service;
import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import java.util.Map;
@@ -16,5 +17,5 @@ public interface AiPosterGenerator {
* @param request 포스터 생성 요청
* @return 생성된 포스터 이미지 URL
*/
String generatePoster(PosterContentCreateRequest request);
String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData);
}
@@ -17,7 +17,7 @@ public interface BlobStorageService {
* @param file 업로드할 파일
* @return 업로드된 파일의 URL
*/
List<String> uploadImage(List<MultipartFile> file);
List<String> uploadImage(List<MultipartFile> file, String containerName);
/**
@@ -34,12 +34,6 @@ public class BlobStorageServiceImpl implements BlobStorageService {
private final BlobServiceClient blobServiceClient;
@Value("${azure.storage.container.poster-images:poster-images}")
private String posterImageContainer;
@Value("${azure.storage.container.content-images:content-images}")
private String contentImageContainer;
@Value("${azure.storage.max-file-size:10485760}") // 10MB
private long maxFileSize;
@@ -60,7 +54,7 @@ public class BlobStorageServiceImpl implements BlobStorageService {
* @return 업로드된 파일의 URL
*/
@Override
public List<String> uploadImage(List<MultipartFile> files) {
public List<String> uploadImage(List<MultipartFile> files, String containerName) {
// 파일 유효성 검증
validateImageFile(files);
List<String> urls = new ArrayList<>();
@@ -70,10 +64,10 @@ public class BlobStorageServiceImpl implements BlobStorageService {
for(MultipartFile file : files) {
String fileName = generateMenuImageFileName(file.getOriginalFilename());
ensureContainerExists(posterImageContainer);
ensureContainerExists(containerName);
// Blob 클라이언트 생성
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(posterImageContainer);
BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName);
BlobClient blobClient = containerClient.getBlobClient(fileName);
// 파일 업로드 (간단한 방식)
@@ -158,12 +152,12 @@ public class BlobStorageServiceImpl implements BlobStorageService {
* @param files 검증할 파일
*/
private void validateImageFile(List<MultipartFile> files) {
for (MultipartFile file : files) {
// 파일 존재 여부 확인
if (file == null || file.isEmpty()) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
// 파일 존재 여부 확인
if (files == null || files.isEmpty()) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND);
}
for (MultipartFile file : files) {
// 파일 크기 확인
if (file.getSize() > maxFileSize) {
throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED);
@@ -0,0 +1,11 @@
package com.won.smarketing.content.domain.service;
import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
/**
* 매장 데이터 제공 도메인 서비스 인터페이스
*/
public interface StoreDataProvider {
StoreWithMenuData getStoreWithMenuData(String userId);
}
@@ -1,5 +1,8 @@
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.content.domain.model.store.MenuData;
import com.won.smarketing.content.domain.model.store.StoreData;
import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import
import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest;
import lombok.RequiredArgsConstructor;
@@ -11,7 +14,9 @@ import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Claude AI를 활용한 포스터 생성 구현체
@@ -34,12 +39,12 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
* @return 생성된 포스터 이미지 URL
*/
@Override
public String generatePoster(PosterContentCreateRequest request) {
public String generatePoster(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
try {
log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl);
// 요청 데이터 구성
Map<String, Object> requestBody = buildRequestBody(request);
Map<String, Object> requestBody = buildRequestBody(request, storeWithMenuData);
log.debug("포스터 생성 요청 데이터: {}", requestBody);
@@ -51,7 +56,7 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
.bodyValue(requestBody)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음
.timeout(Duration.ofSeconds(90))
.block();
// 응답에서 content(이미지 URL) 추출
@@ -75,9 +80,32 @@ public class PythonAiPosterGenerator implements AiPosterGenerator {
* Python 서비스의 PosterContentGetRequest 모델에 맞춤
* 카테고리,
*/
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request) {
private Map<String, Object> buildRequestBody(PosterContentCreateRequest request, StoreWithMenuData storeWithMenuData) {
Map<String, Object> requestBody = new HashMap<>();
// TODO : 매장 정보 호출 후 request
// StoreData storeData = storeWithMenuData.getStoreData();
// List<MenuData> menuDataList = storeWithMenuData.getMenuDataList();
//
// List<Map<String, Object>> menuList = menuDataList.stream()
// .map(menu -> {
// Map<String, Object> 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());
//
// requestBody.put("store_name", storeData.getStoreName());
// requestBody.put("business_type", storeData.getBusinessType());
// requestBody.put("location", storeData.getLocation());
// requestBody.put("seat_count", storeData.getSeatCount());
// requestBody.put("menu_list", menuList);
// 기본 정보
requestBody.put("title", request.getTitle());
requestBody.put("category", request.getCategory());
@@ -0,0 +1,310 @@
package com.won.smarketing.content.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.content.domain.model.store.MenuData;
import com.won.smarketing.content.domain.model.store.StoreData;
import com.won.smarketing.content.domain.model.store.StoreWithMenuData;
import com.won.smarketing.content.domain.service.StoreDataProvider;
import jakarta.servlet.http.HttpServletRequest;
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.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 매장 API 데이터 제공자 구현체
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class StoreApiDataProvider implements StoreDataProvider {
private final WebClient webClient;
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
@Value("${external.store-service.timeout}")
private int timeout;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
public StoreWithMenuData getStoreWithMenuData(String userId) {
log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId);
try {
// 매장 정보와 메뉴 정보를 병렬로 조회
StoreData storeData = getStoreDataByUserId(userId);
List<MenuData> menuDataList = getMenusByStoreId(storeData.getStoreId());
StoreWithMenuData result = StoreWithMenuData.builder()
.storeData(storeData)
.menuDataList(menuDataList)
.build();
log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}",
storeData.getStoreId(), storeData.getStoreName(), menuDataList.size());
return result;
} catch (Exception e) {
log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e);
// 실패 시 Mock 데이터 반환
return StoreWithMenuData.builder()
.storeData(createMockStoreData(userId))
.menuDataList(createMockMenuData(6L))
.build();
}
}
public StoreData getStoreDataByUserId(String userId) {
try {
log.debug("매장 정보 실시간 조회: userId={}", userId);
return callStoreServiceByUserId(userId);
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage());
return createMockStoreData(userId);
}
}
public List<MenuData> getMenusByStoreId(Long storeId) {
log.info("매장 메뉴 조회 시작: storeId={}", storeId);
try {
return callMenuService(storeId);
} catch (Exception e) {
log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockMenuData(storeId);
}
}
private StoreData callStoreServiceByUserId(String userId) {
try {
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store")
.header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
log.info("response : {}", response.getData().getStoreName());
log.info("response : {}", response.getData().getStoreId());
if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder()
.storeId(storeInfo.getStoreId())
.storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress())
.description(storeInfo.getDescription())
.seatCount(storeInfo.getSeatCount())
.build();
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
log.error("매장 서비스 호출 실패: {}", e.getMessage());
}
return createMockStoreData(userId);
}
private String getCurrentJwtToken() {
try {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음");
return null;
}
HttpServletRequest request = attributes.getRequest();
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
String token = bearerToken.substring(BEARER_PREFIX.length());
log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length())));
return token;
} else {
log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken);
return null;
}
} catch (Exception e) {
log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage());
return null;
}
}
private List<MenuData> callMenuService(Long storeId) {
try {
MenuApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/menu/store/" + storeId)
.retrieve()
.bodyToMono(MenuApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null && !response.getData().isEmpty()) {
List<MenuData> menuDataList = response.getData().stream()
.map(this::toMenuData)
.collect(Collectors.toList());
log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size());
return menuDataList;
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId);
return Collections.emptyList();
}
log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage());
} catch (WebClientException e) {
log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage());
}
return createMockMenuData(storeId);
}
/**
* MenuResponse를 MenuData로 변환
*/
private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) {
return MenuData.builder()
.menuId(menuInfo.getMenuId())
.menuName(menuInfo.getMenuName())
.category(menuInfo.getCategory())
.price(menuInfo.getPrice())
.description(menuInfo.getDescription())
.build();
}
private StoreData createMockStoreData(String userId) {
return StoreData.builder()
.storeName("테스트 카페 " + userId)
.businessType("카페")
.location("서울시 강남구")
.build();
}
private List<MenuData> createMockMenuData(Long storeId) {
log.info("Mock 메뉴 데이터 생성: storeId={}", storeId);
return List.of(
MenuData.builder()
.menuId(1L)
.menuName("아메리카노")
.category("음료")
.price(4000)
.description("깊고 진한 맛의 아메리카노")
.build(),
MenuData.builder()
.menuId(2L)
.menuName("카페라떼")
.category("음료")
.price(4500)
.description("부드러운 우유 거품이 올라간 카페라떼")
.build(),
MenuData.builder()
.menuId(3L)
.menuName("치즈케이크")
.category("디저트")
.price(6000)
.description("진한 치즈 맛의 수제 케이크")
.build()
);
}
@Getter
private static class StoreApiResponse {
private int status;
private String message;
private StoreInfo data;
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; }
@Getter
static class StoreInfo {
private Long storeId;
private String storeName;
private String businessType;
private String address;
private String description;
private Integer seatCount;
}
}
/**
* Menu API 응답 DTO (새로 추가)
*/
private static class MenuApiResponse {
private List<MenuInfo> data;
private String message;
private boolean success;
public List<MenuInfo> getData() { return data; }
public void setData(List<MenuInfo> data) { this.data = data; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public static class MenuInfo {
private Long menuId;
private String menuName;
private String category;
private Integer price;
private String description;
private String image;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public Long getMenuId() { return menuId; }
public void setMenuId(Long menuId) { this.menuId = menuId; }
public String getMenuName() { return menuName; }
public void setMenuName(String menuName) { this.menuName = menuName; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Integer getPrice() { return price; }
public void setPrice(Integer price) { this.price = price; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getImage() { return image; }
public void setImage(String image) { this.image = image; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
}
}
@@ -9,9 +9,10 @@ import com.won.smarketing.content.application.usecase.SnsContentUseCase;
import com.won.smarketing.content.presentation.dto.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -26,17 +27,16 @@ import java.util.List;
* SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공
*/
@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API")
@Slf4j
@RestController
@RequestMapping("/api/content")
@RequiredArgsConstructor
public class ContentController {
@Autowired
private ObjectMapper objectMapper;
private final SnsContentUseCase snsContentUseCase;
private final PosterContentUseCase posterContentUseCase;
private final ContentQueryUseCase contentQueryUseCase;
private final ObjectMapper objectMapper;
/**
* SNS 게시물 생성
@@ -46,7 +46,7 @@ public class ContentController {
@Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.")
@PostMapping(path = "/sns/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<SnsContentCreateResponse>> generateSnsContent(@Valid @RequestPart("request") String requestJson,
@Valid @RequestPart("files") List<MultipartFile> images) throws JsonProcessingException {
@Valid @RequestPart(name = "files", required = false) List<MultipartFile> images) throws JsonProcessingException {
SnsContentCreateRequest request = objectMapper.readValue(requestJson, SnsContentCreateRequest.class);
SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request, images);
return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다."));
@@ -72,15 +72,22 @@ public class ContentController {
* @return 생성된 포스터 콘텐츠 정보
*/
@Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.")
@PostMapping("/poster/generate")
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) {
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request);
@PostMapping(value = "/poster/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<PosterContentCreateResponse>> generatePosterContent(
@Parameter(content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE))
@RequestPart(value = "images", required = false) List<MultipartFile> images,
@RequestPart("request") String requestJson) throws JsonProcessingException {
// JSON 파싱
PosterContentCreateRequest request = objectMapper.readValue(requestJson, PosterContentCreateRequest.class);
PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(images, request);
return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다."));
}
/**
* 홍보 포스터 저장
*
*
* @param request 포스터 콘텐츠 저장 요청
* @return 저장 성공 응답
*/
@@ -50,9 +50,7 @@ public class PosterContentCreateRequest {
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "업로드된 이미지 URL 목록", required = true)
@NotNull(message = "이미지는 1개 이상 필수입니다")
@Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다")
@Schema(description = "업로드된 이미지 URL 목록")
private List<String> images;
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
@@ -31,19 +31,9 @@ public class PosterContentCreateResponse {
@Schema(description = "생성된 포스터 타입")
private String contentType;
@Schema(description = "포스터 이미지 URL")
private String posterImage;
@Schema(description = "원본 이미지 URL 목록")
private List<String> originalImages;
@Schema(description = "이미지 스타일", example = "모던")
private String imageStyle;
@Schema(description = "생성 상태", example = "DRAFT")
private String status;
@Schema(description = "포스터사이즈", example = "800x600")
private Map<String, String> posterSizes;
}
@@ -1,8 +1,6 @@
// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java
package com.won.smarketing.content.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -19,12 +17,7 @@ import java.util.List;
@Schema(description = "포스터 콘텐츠 저장 요청")
public class PosterContentSaveRequest {
// @Schema(description = "콘텐츠 ID", example = "1", required = true)
// @NotNull(message = "콘텐츠 ID는 필수입니다")
// private Long contentId;
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다")
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "제목", example = "특별 이벤트 안내")
@@ -36,22 +29,12 @@ public class PosterContentSaveRequest {
@Schema(description = "선택된 포스터 이미지 URL")
private List<String> images;
@Schema(description = "발행 상태", example = "PUBLISHED")
private String status;
// CreationConditions에 필요한 필드들
@Schema(description = "콘텐츠 카테고리", example = "이벤트")
private String category;
@Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요")
private String requirement;
@Schema(description = "톤앤매너", example = "전문적")
private String toneAndManner;
@Schema(description = "감정 강도", example = "보통")
private String emotionIntensity;
@Schema(description = "이벤트명", example = "신메뉴 출시 이벤트")
private String eventName;
@@ -68,18 +68,6 @@ public class SnsContentCreateRequest {
@Schema(description = "콘텐츠 타입", example = "SNS 게시물")
private String contentType;
// @Schema(description = "톤앤매너",
// example = "친근함",
// allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"})
// private String toneAndManner;
// @Schema(description = "감정 강도",
// example = "보통",
// allowableValues = {"약함", "보통", "강함"})
// private String emotionIntensity;
// ==================== 이벤트 정보 ====================
@Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)",
example = "신메뉴 출시 이벤트")
@Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요")
@@ -37,6 +37,10 @@ logging:
external:
ai-service:
base-url: ${AI_SERVICE_BASE_URL:http://20.249.113.247:5001}
store-service:
base-url: ${STORE_SERVICE_URL:http://smarketing.20.249.184.228.nip.io}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
azure:
storage:
account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02}
@@ -67,4 +71,7 @@ info:
app:
name: ${APP_NAME:smarketing-content}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - content"
description: "AI 마케팅 서비스 MVP - content"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
@@ -1,7 +1,8 @@
package com.won.smarketing.common.config;
package com.won.smarketing.member.config;
import com.won.smarketing.common.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -25,10 +26,13 @@ import java.util.Arrays;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
public class SecurityConfig
{
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${allowed-origins}")
private String allowedOrigins;
/**
* Spring Security 필터 체인 설정
*
@@ -43,9 +47,10 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
.requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
"/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
"/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error").permitAll()
"/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@@ -71,7 +76,7 @@ public class SecurityConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
@@ -53,4 +53,6 @@ info:
app:
name: ${APP_NAME:smarketing-member}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - member"
description: "AI 마케팅 서비스 MVP - member"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}
-6
View File
@@ -1,10 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
// Azure Blob Storage 의존성 추가
implementation 'com.azure:azure-storage-blob:12.25.0'
implementation 'com.azure:azure-identity:1.11.1'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3'
}
@@ -0,0 +1,88 @@
package com.won.smarketing.store.config;
import com.won.smarketing.common.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정 클래스
* JWT 기반 인증 및 CORS 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig
{
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Value("${allowed-origins}")
private String allowedOrigins;
/**
* Spring Security 필터 체인 설정
*
* @param http HttpSecurity 객체
* @return SecurityFilterChain
* @throws Exception 예외
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.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/**", "/actuator/**", "/health/**", "/error"
).permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* 패스워드 인코더 빈 등록
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* CORS 설정
*
* @return CorsConfigurationSource
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
@@ -46,12 +46,12 @@ public class StoreCreateRequest {
@Schema(description = "좌석 수", example = "20")
private Integer seatCount;
@Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore")
@Schema(description = "SNS 계정 정보", example = "@mystore")
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
private String instaAccounts;
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
@Schema(description = "블로그 계정 정보", example = "mystore")
private String blogAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@@ -47,10 +47,10 @@ public class StoreResponse {
@Schema(description = "좌석 수", example = "20")
private Integer seatCount;
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
@Schema(description = "블로그 계정 정보", example = "mystore")
private String blogAccounts;
@Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
@Schema(description = "인스타 계정 정보", example = "@mystore")
private String instaAccounts;
@Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.")
@@ -43,11 +43,11 @@ public class StoreUpdateRequest {
@Schema(description = "좌석 수", example = "20")
private Integer seatCount;
@Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore")
@Schema(description = "인스타 계정 정보", example = "@mystore")
@Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다")
private String instaAccounts;
@Schema(description = "블로그 계정 정보", example = "블로그: mystore")
@Schema(description = "블로그 계정 정보", example = "mystore")
@Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다")
private String blogAccounts;
@@ -68,4 +68,6 @@ info:
app:
name: ${APP_NAME:smarketing-content}
version: "1.0.0-MVP"
description: "AI 마케팅 서비스 MVP - content"
description: "AI 마케팅 서비스 MVP - content"
allowed-origins: ${ALLOWED_ORIGINS:http://localhost:3000}